Proxy Desktop App License Activation Through a Cloudflare Worker
A desktop app binary cannot safely hide a payment provider API key.
If you embed a secret in a Tauri, Electron, or native desktop app, assume a motivated user can extract it. Environment variables, Rust env!(), minified JavaScript, and bundled config files do not change the trust boundary. The binary ships to the user's machine.
For license activation, a better pattern is to proxy the provider call through a small backend you control. A Cloudflare Worker is enough for many solo desktop apps.
The problem: desktop binaries cannot hide API keys
The unsafe shape is:
desktop app -> Lemon Squeezy API
To call the provider directly, the app needs a credential. If that credential can activate, validate, or inspect licenses, it should not be distributed to every customer.
Even if you use Rust and compile the app, the key is still part of the distributed artifact or runtime environment. Treat client-side secrets as public.
Safer architecture: app to Worker to license provider
Use this shape instead:
desktop app -> Cloudflare Worker -> license provider API
The desktop app sends only user input and device context:
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"instance_name": "device-or-installation-id"
}
The Worker adds the provider API key server-side, calls the license API, and returns a narrow result:
{
"ok": true,
"status": "active",
"instance_id": "instance_123"
}
Do not return the raw provider response unless the UI genuinely needs it. A narrow app-level contract is easier to test and harder to misuse.
Store provider credentials as Worker secrets
Use Wrangler secrets:
npx wrangler secret put LEMON_SQUEEZY_API_KEY
The Worker can then read the secret from env:
export interface Env {
LEMON_SQUEEZY_API_KEY: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const body = await request.json();
const providerResp = await fetch("https://api.lemonsqueezy.com/v1/licenses/activate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.LEMON_SQUEEZY_API_KEY}`,
},
body: JSON.stringify({
license_key: body.license_key,
instance_name: body.instance_name,
}),
});
const data = await providerResp.json();
return Response.json({
ok: Boolean(data.activated),
status: data.license_key?.status ?? "unknown",
instance_id: data.instance?.id ?? null,
});
},
};
This is not a complete production Worker. It shows the boundary: the desktop app never receives the provider API key.
Validate input before spending API quota
Reject obviously invalid input at the Worker boundary:
function isPossibleLicenseKey(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= 256;
}
Then return a stable app-level error:
if (!isPossibleLicenseKey(body.license_key)) {
return Response.json({ ok: false, reason: "invalid_format" }, { status: 400 });
}
The desktop app should be able to distinguish:
- invalid input
- provider rejection
- activation limit reached
- network failure
- Worker/server failure
Do not collapse everything into false.
Handle desktop WebView origins deliberately
Tauri and WebView-based apps may not send the same Origin shape as a normal hosted web app. If the request does not use cookies and does not rely on browser credentials, a permissive CORS response may be acceptable:
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
Handle preflight:
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
This is not a substitute for license validation. It only lets the desktop WebView reach your Worker. The security boundary is the server-side secret and provider-side license rules.
Return narrow states to the app
Design the Worker response around what the UI and Rust commands need:
type LicenseActivationResponse =
| { ok: true; status: "active"; instanceId: string }
| { ok: false; reason: "invalid_format" | "invalid_key" | "limit_reached" | "network_error" };
This keeps provider changes from leaking through every frontend component.
On the Tauri side, map that response into your own app state:
pub struct LicenseState {
pub is_licensed: bool,
pub masked_key: Option<String>,
pub activated_at: Option<String>,
}
That state is what the UI should render. The provider payload is an integration detail.
Verification checklist before shipping
Before using this pattern in a paid app, test:
- the Worker has the secret in production
- the secret is not present in the desktop repository or binary config
- invalid license input returns
400without calling the provider - valid activation returns the expected app-level state
- activation-limit errors are shown clearly
- Worker logs do not print full license keys
- the desktop app handles Worker downtime as a recoverable network error