Building a client-side web app has a genuine tension: you want to sell premium features, but the entire app runs in the browser. There's no server-side session to check, no database call to gate access. How do you stop someone from simply opening DevTools and bypassing your paywall?
The honest answer is: you can't stop a determined developer. But for a paid tool in the $9–$99 range, "determined developer" is not your threat model. Your threat model is ordinary users, and for them a well-designed license system is more than enough.
Here's how to build one that works without a persistent backend.
The architecture
The system has four components:
- Gumroad — handles payment and generates a license key per purchase
- A Cloudflare Worker — a small serverless function that validates keys against Gumroad's API
- Client-side license cache — stores the validated result in
localStoragewith a tamper-resistant integrity token - The app itself — checks the cache on startup and gates premium features accordingly
The Worker is the only server-side component. It runs on Cloudflare's edge, costs effectively nothing at low scale, and has no database of its own — it delegates to Gumroad for the ground truth.
Step 1: Set up Gumroad
Create a product on Gumroad and enable license keys in the product settings. Gumroad generates a unique key per purchase in the format XXXX-XXXX-XXXX-XXXX. Keep your Gumroad API key — you'll need it in the Worker.
Step 2: The Cloudflare Worker
Create a Worker that accepts a POST request with a license key, validates it against Gumroad, and returns a signed response. Deploy with Wrangler:
export default {
async fetch(request, env) {
// CORS — lock to your domain
const origin = request.headers.get("Origin");
if (origin !== "https://yourapp.com") {
return new Response("Forbidden", { status: 403 });
}
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
// Rate limiting by IP — protect against brute force
const ip = request.headers.get("CF-Connecting-IP");
const rateLimitKey = `rate:${ip}`;
const count = parseInt((await env.KV.get(rateLimitKey)) ?? "0");
if (count > 10) {
return new Response(JSON.stringify({ valid: false, error: "Rate limit exceeded" }), {
status: 429,
headers: { "Content-Type": "application/json" },
});
}
await env.KV.put(rateLimitKey, String(count + 1), { expirationTtl: 60 });
const { key, productId } = await request.json();
// Validate against Gumroad
const gumroadRes = await fetch("https://api.gumroad.com/v2/licenses/verify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
product_id: productId,
license_key: key,
increment_uses_count: "false",
}),
});
const data = await gumroadRes.json();
const valid = data.success === true;
return new Response(JSON.stringify({ valid }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": origin,
},
});
},
};
Key decisions here:
- CORS locked to your origin — prevents other sites from using your validator
- IP rate limiting via KV — 10 attempts per minute per IP; blocks brute-force key guessing
increment_uses_count: false— if you want device limits, track activations yourself in KV rather than relying on Gumroad's counter
Step 3: Client-side caching with integrity check
Calling the Worker on every app load is slow and unnecessary. Cache the result locally, but make it tamper-resistant:
const SALT = "your-app-specific-salt-here";
async function hashToken(key, timestamp) {
const data = new TextEncoder().encode(`${key}:${timestamp}:${SALT}`);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function saveLicense(key) {
const timestamp = Date.now();
const hash = await hashToken(key, timestamp);
localStorage.setItem("license", JSON.stringify({ key, timestamp, hash }));
}
async function checkLicense() {
const stored = localStorage.getItem("license");
if (!stored) return false;
try {
const { key, timestamp, hash } = JSON.parse(stored);
const expected = await hashToken(key, timestamp);
if (hash !== expected) return false; // tampered
// Re-validate against server every 30 days
const ageMs = Date.now() - timestamp;
if (ageMs > 30 * 24 * 60 * 60 * 1000) {
return await validateWithServer(key);
}
return true;
} catch {
return false;
}
}
The integrity token is a SHA-256 hash of the key, timestamp, and a fixed salt. If someone edits localStorage manually, the hash won't match and they get bounced to the free tier. The salt should be embedded in your app — obfuscate it, but accept that a determined developer can find it. Again, that's not your threat model.
Re-validating against the server every 30 days handles revoked keys (refunds, chargebacks).
Step 4: Obfuscation
Bundle obfuscation raises the bar meaningfully for a sub-$100 product. Tools like javascript-obfuscator make it genuinely tedious to reverse-engineer the premium check, which is enough to deter casual circumvention. It won't stop a determined developer, but it doesn't need to.
Run obfuscation as a build step, not during development. It makes debugging impossible and you'll spend hours chasing phantom bugs.
What this gets you
A license system with no database, no recurring server cost, and no session management. Validation happens at activation, the result is cached locally, and the Worker only gets called once every 30 days per device. The entire backend fits in 50 lines and runs on Cloudflare's free tier.
For an indie product with a few hundred users, this is more than sufficient — and it's production-ready without any infrastructure to manage.
We've built this exact system for our own products. If you're building a paid web app and need help with the architecture, get in touch.