LinhGo Labs
LinhGo Labs
Build a Simple License Server with a Free Cloudflare Worker

Build a Simple License Server with a Free Cloudflare Worker

A practical guide to building a license server on Cloudflare Workers — no backend, no VPS, and it runs on the free tier.

So you’ve shipped a small desktop app, a plugin, or a private tool. Sales are coming in. At some point someone asks: “Can I install this on my other computer too?”

And you realize you have no way to answer that question programmatically.

You could ignore it. You could do it manually. Or you could spend a weekend building a tiny license server that handles it for you — and not have to pay anything to run it.

That last option is what this post is about.


A license API with five endpoints:

EndpointWhat it doesAdmin only?
/register-licenseCreates a license key for an emailYes
/license-infoLooks up license detailsYes
/activateActivates a license on a machineNo
/validateChecks if this machine is activatedNo
/deactivateRemoves a machine from the licenseNo

Each license can be activated on up to three machines. You can change that number.

The whole thing runs on:

  • Cloudflare Workers — handles the API logic
  • Cloudflare KV — stores license data
  • Wrangler CLI — deploys everything

When you create a license, two records go into KV.

One maps the customer’s email to their key:

email:customer@example.com -> ABCD-EFGH-2345-JKLM

The other stores the actual license object:

license:ABCD-EFGH-2345-JKLM -> license data

The license object itself is JSON:

{
  "key": "ABCD-EFGH-2345-JKLM",
  "email": "customer@example.com",
  "createdAt": 1719000000000,
  "maxMachines": 3,
  "disabled": false,
  "machines": [
    {
      "machineId": "machine-001",
      "activatedAt": 1719000000000
    }
  ]
}

Simple. No fancy schema, no migrations, nothing to maintain.


You’ll need:

  • A Cloudflare account (free)
  • Node.js installed
  • Wrangler CLI

Install Wrangler if you haven’t already:

npm install -g wrangler

Log in:

wrangler login

Your browser opens and asks you to authorize. Click through, come back to the terminal.


npm create cloudflare@latest license-server

When it asks what to create, go with Hello World Worker, JavaScript, no framework.

Then:

cd license-server

You’ll have something like:

license-server/
  src/
    index.js
  wrangler.toml
  package.json

KV is where we store the license data. Run:

wrangler kv namespace create APP_DATA

Cloudflare will print something like this:

[[kv_namespaces]]
binding = "APP_DATA"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Copy that block and paste it into your wrangler.toml. Your config should end up looking like:

name = "license-server"
main = "src/index.js"
compatibility_date = "2024-06-01"

[[kv_namespaces]]
binding = "APP_DATA"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

The binding name APP_DATA is what the Worker code uses to access KV. Keep it matching.


You don’t want just anyone creating licenses. The admin endpoints are protected by a secret header.

wrangler secret put ADMIN_SECRET

Enter something long and random when prompted. Something like:

my-super-secret-admin-key-do-not-share

A few things to be clear about here: do not put this secret in your app binary, your Electron renderer, your frontend JavaScript, or anywhere a user could extract it. It’s only for your own scripts, your backend, or your terminal.


Open src/index.js and replace the contents with the license server code.

The Worker handles routing, KV reads and writes, admin authorization, and JSON responses. Here’s the full code:

const MAX_MACHINES = 3;
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

function generateKey() {
  const segment = () =>
    Array.from({ length: 4 }, () =>
      ALPHABET[Math.floor(Math.random() * ALPHABET.length)]
    ).join("");
  return `${segment()}-${segment()}-${segment()}-${segment()}`;
}

function isAdmin(request, env) {
  const auth = request.headers.get("Authorization") || "";
  return auth === `Bearer ${env.ADMIN_SECRET}`;
}

function json(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const path = url.pathname;

    if (request.method === "OPTIONS") {
      return new Response(null, {
        headers: {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "POST, OPTIONS",
          "Access-Control-Allow-Headers": "Content-Type, Authorization",
        },
      });
    }

    if (request.method !== "POST") {
      return json({ success: false, message: "Method not allowed" }, 405);
    }

    let body = {};
    try {
      body = await request.json();
    } catch {
      return json({ success: false, message: "Invalid JSON" }, 400);
    }

    // Admin: register a new license
    if (path === "/register-license") {
      if (!isAdmin(request, env)) return json({ success: false, message: "Unauthorized" }, 401);

      const { email } = body;
      if (!email) return json({ success: false, message: "Email required" }, 400);

      const existing = await env.APP_DATA.get(`email:${email}`);
      if (existing) {
        return json({ success: false, message: "Email already registered", key: existing });
      }

      const key = generateKey();
      const license = {
        key,
        email,
        createdAt: Date.now(),
        maxMachines: MAX_MACHINES,
        disabled: false,
        machines: [],
      };

      await env.APP_DATA.put(`license:${key}`, JSON.stringify(license));
      await env.APP_DATA.put(`email:${email}`, key);

      return json({ success: true, key, email });
    }

    // Admin: look up license info
    if (path === "/license-info") {
      if (!isAdmin(request, env)) return json({ success: false, message: "Unauthorized" }, 401);

      const { email, key } = body;
      let licenseKey = key;

      if (!licenseKey && email) {
        licenseKey = await env.APP_DATA.get(`email:${email}`);
      }

      if (!licenseKey) return json({ success: false, message: "License not found" }, 404);

      const raw = await env.APP_DATA.get(`license:${licenseKey.toUpperCase()}`);
      if (!raw) return json({ success: false, message: "License not found" }, 404);

      return json({ success: true, license: JSON.parse(raw) });
    }

    // Public: activate a machine
    if (path === "/activate") {
      const { key, machineId } = body;
      if (!key || !machineId) return json({ success: false, message: "key and machineId required" }, 400);

      const raw = await env.APP_DATA.get(`license:${key.toUpperCase()}`);
      if (!raw) return json({ success: false, message: "License not found" }, 404);

      const license = JSON.parse(raw);

      if (license.disabled) return json({ success: false, message: "License disabled" }, 403);

      const already = license.machines.find((m) => m.machineId === machineId);
      if (already) {
        return json({ success: true, premium: true, remainingSlots: license.maxMachines - license.machines.length });
      }

      if (license.machines.length >= license.maxMachines) {
        return json({ success: false, message: "Activation limit exceeded" }, 403);
      }

      license.machines.push({ machineId, activatedAt: Date.now() });
      await env.APP_DATA.put(`license:${key.toUpperCase()}`, JSON.stringify(license));

      return json({ success: true, premium: true, remainingSlots: license.maxMachines - license.machines.length });
    }

    // Public: validate a machine
    if (path === "/validate") {
      const { key, machineId } = body;
      if (!key || !machineId) return json({ success: false, premium: false });

      const raw = await env.APP_DATA.get(`license:${key.toUpperCase()}`);
      if (!raw) return json({ success: false, premium: false });

      const license = JSON.parse(raw);

      if (license.disabled) return json({ success: false, premium: false });

      const activated = license.machines.some((m) => m.machineId === machineId);
      return json({ success: activated, premium: activated });
    }

    // Public: deactivate a machine
    if (path === "/deactivate") {
      const { key, machineId } = body;
      if (!key || !machineId) return json({ success: false, message: "key and machineId required" }, 400);

      const raw = await env.APP_DATA.get(`license:${key.toUpperCase()}`);
      if (!raw) return json({ success: false, message: "License not found" }, 404);

      const license = JSON.parse(raw);
      license.machines = license.machines.filter((m) => m.machineId !== machineId);

      await env.APP_DATA.put(`license:${key.toUpperCase()}`, JSON.stringify(license));

      return json({ success: true });
    }

    return json({ success: false, message: "Not found" }, 404);
  },
};

A few things worth noting:

  • License keys use a custom alphabet that removes I, O, 0, and 1 — the characters people always confuse when typing.
  • Keys are stored and looked up in uppercase so k7fh-wp3a... and K7FH-WP3A... are the same thing.
  • If a machine that’s already activated calls /activate again, it just returns success without consuming another slot. This matters for reinstalls.

wrangler deploy

You’ll get a URL like:

https://license-server.your-name.workers.dev

That’s your API. Save it.


Set up two shell variables so the commands are less verbose:

API_URL="https://license-server.your-name.workers.dev"
ADMIN_SECRET="my-super-secret-admin-key-do-not-share"
curl -X POST "$API_URL/register-license" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_SECRET" \
  -d '{"email":"customer@example.com"}'

Response:

{
  "success": true,
  "key": "K7FH-WP3A-M9QZ-B2CD",
  "email": "customer@example.com"
}

Send that key to your customer however you want — email, your payment platform’s fulfillment flow, a manual Slack message, whatever works.

curl -X POST "$API_URL/register-license" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_SECRET" \
  -d '{"email":"customer@example.com"}'
{
  "success": false,
  "message": "Email already registered",
  "key": "K7FH-WP3A-M9QZ-B2CD"
}

It won’t create a duplicate. Handy if your webhook fires twice or you accidentally run the command again.

By email:

curl -X POST "$API_URL/license-info" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_SECRET" \
  -d '{"email":"customer@example.com"}'

Or by key:

curl -X POST "$API_URL/license-info" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_SECRET" \
  -d '{"key":"K7FH-WP3A-M9QZ-B2CD"}'

Response:

{
  "success": true,
  "license": {
    "key": "K7FH-WP3A-M9QZ-B2CD",
    "email": "customer@example.com",
    "createdAt": 1719000000000,
    "maxMachines": 3,
    "disabled": false,
    "machines": []
  }
}

No machines activated yet — the customer just got the key.


Your app calls /activate when the customer enters their key for the first time.

curl -X POST "$API_URL/activate" \
  -H "Content-Type: application/json" \
  -d '{"key":"K7FH-WP3A-M9QZ-B2CD","machineId":"machine-001"}'
{
  "success": true,
  "premium": true,
  "remainingSlots": 2
}

Two slots left. The customer can still activate on two more machines.

If they’ve already used all three:

{
  "success": false,
  "message": "Activation limit exceeded"
}

Your app should show something friendly here, like:

This license has been activated on the maximum number of devices.
Deactivate another device, or contact support if you need help.

Your app calls /validate on every startup.

curl -X POST "$API_URL/validate" \
  -H "Content-Type: application/json" \
  -d '{"key":"K7FH-WP3A-M9QZ-B2CD","machineId":"machine-001"}'

If the machine is activated:

{
  "success": true,
  "premium": true
}

If not — wrong key, disabled license, or the machine was never activated:

{
  "success": false,
  "premium": false
}

When a customer wants to move to a new computer:

curl -X POST "$API_URL/deactivate" \
  -H "Content-Type: application/json" \
  -d '{"key":"K7FH-WP3A-M9QZ-B2CD","machineId":"machine-001"}'
{
  "success": true
}

The machine is removed. The slot is freed. The customer can activate on their new machine.

You can expose this through your app’s settings screen, or just handle it manually through support emails. Either works.


Your app needs a stable, unique ID per machine. The important thing is: don’t send raw hardware identifiers like MAC addresses or CPU serial numbers to a third-party server. It’s unnecessary and feels invasive.

The simplest approach that works fine for most apps:

  1. On first launch, generate a random UUID.
  2. Save it to local app settings (or wherever makes sense for your platform).
  3. Use that UUID as the machine ID forever.
machineId = random UUID saved in app config

If you want a bit more anonymization:

machineId = SHA256(local install UUID)

It’s stable, unique, and you’re not shipping anything identifiable off the device.


  1. App generates (or loads) a local machine ID.
  2. User types in their key.
  3. App calls /activate.
  4. If successful, save the key locally and unlock premium features.
  1. Load saved key and machine ID.
  2. Call /validate.
  3. If valid, premium stays on.
  4. If not valid, drop back to free mode and prompt the user.

Your app doesn’t need much. Two functions cover the main cases:

const API_URL = "https://license-server.your-name.workers.dev";

async function activateLicense(key, machineId) {
  const res = await fetch(`${API_URL}/activate`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ key, machineId }),
  });
  return res.json();
}

async function validateLicense(key, machineId) {
  const res = await fetch(`${API_URL}/validate`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ key, machineId }),
  });
  return res.json();
}

Using them:

const result = await activateLicense("K7FH-WP3A-M9QZ-B2CD", "machine-001");

if (result.success && result.premium) {
  console.log("Unlocked");
} else {
  console.log("Not valid");
}

Desktop apps and backend scripts don’t need it. If you’re calling the Worker from a browser (for a web app), add this to your Worker’s response headers:

Access-Control-Allow-Origin: https://yourdomain.com
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

The OPTIONS handler in the code above already returns those headers. You just need to tighten the Allow-Origin value if you don’t want to allow all origins.


This server doesn’t process payments — that’s intentional. It’s just the license layer.

A simple integration could look like this:

  1. Customer pays through Stripe, Lemon Squeezy, Gumroad, Paddle, etc.
  2. Your backend (or a webhook) receives the purchase event.
  3. It calls /register-license with the customer’s email.
  4. The generated key gets emailed to the customer.

If you’re doing manual sales right now, just run the curl command yourself and copy-paste the key into an email. That’s genuinely fine when you’re starting out.


The license JSON includes a disabled flag:

{
  "disabled": false
}

Both /activate and /validate check this. If it’s true, both endpoints return failure.

There’s no built-in admin endpoint to flip this yet — but you can edit the KV record manually from the Cloudflare dashboard. Just navigate to Workers & Pages → KV → your namespace, find the license key, and edit the JSON.

Useful for: refunds, chargebacks, test keys you want to expire, suspicious accounts.


If your app runs locally, a determined person can patch it. That’s true for basically every client-side licensing system.

The goal here isn’t to make cracking impossible. It’s to keep honest customers honest and discourage casual sharing. That’s usually enough for small indie products.

For normal usage this doesn’t matter. But if two activation requests come in at the exact same millisecond, it’s theoretically possible to exceed the machine limit by one. In practice, for an indie product, you’ll probably never hit this.

If you need stronger guarantees, look at Cloudflare Durable Objects or D1.

If someone gets it, they can create unlimited licenses. Keep it only on your server, your local machine, and inside your webhook handler. Never in your app bundle.


Once the basics are working:

  • An admin endpoint to disable a license (instead of editing KV manually)
  • Expiration dates
  • Endpoints to reset or increase machine limits for a customer
  • A small internal dashboard for managing licenses
  • Webhook integration with your payment provider
  • Signed offline tokens (for apps that can’t call home)

But don’t build any of that yet. Ship the simple version first and see if you actually need it.


Your request is missing or has a wrong Authorization header. It should be:

Authorization: Bearer YOUR_ADMIN_SECRET

Double-check the secret you set with wrangler secret put ADMIN_SECRET. Redeploy after changing secrets if needed.

Make sure the key is correct. The server normalizes to uppercase, so lowercase input is fine — but extra spaces or wrong characters will cause a miss.

Also confirm you’re hitting the right Worker URL.

All machine slots are used. Call /license-info to see which machines are activated. Deactivate one to free a slot.

Your wrangler.toml needs:

[[kv_namespaces]]
binding = "APP_DATA"
id = "your-kv-namespace-id"

The binding name in the TOML (APP_DATA) must match what the code references. If you rename one, rename both.


For a lot of indie projects, this is genuinely all you need. No VPS, no database, no monthly bill until you’re doing real volume.

You’ve got:

  • license key generation
  • email-to-key mapping
  • machine activation with a configurable limit
  • machine validation on startup
  • deactivation when customers need to move devices
  • admin-only endpoints for creating and viewing licenses

Start here. Add the fancier stuff only if you actually run into the limits.