All articles
Cloudflare WorkersMonetisationServerlessJavaScript

Licensing a Paid Web App Without a Backend

17 June 20265 min readAD IT Consulting

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:

  1. Gumroad — handles payment and generates a license key per purchase
  2. A Cloudflare Worker — a small serverless function that validates keys against Gumroad's API
  3. Client-side license cache — stores the validated result in localStorage with a tamper-resistant integrity token
  4. 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:

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.

More on this topic

Building a web app?

We write from experience — if you need help with any of this in your own environment, get in touch.

Talk to us
Work with us

Ready to build something worth using?

Tell us what you're working on. We respond within one business day.

Start a conversation