If you sell a desktop app, CLI, plugin or template, you eventually need license keys: one per sale, checked inside the app, and switched off on refund. Hosted services do this for roughly $29 a month. You can also run the whole thing yourself on Cloudflare Workers + KV, with no server to babysit. This post walks through the design I settled on. It is useful whether or not you use my kit (more on that at the end; I made it).

What the server has to do

  • Create a key (admin only) and show it once.
  • Validate a key (public endpoint) from your app.
  • Limit activations per machine.
  • Revoke a key.
  • Create keys automatically when a sale happens, and revoke on refund.

That is a small surface, which suits a Worker.

1. Key format and storage

Generate keys from 100 bits of randomness, encoded in Crockford base32, grouped for humans: KB-7K2XM-9QF3T-0ABCD-HJ4WZ. Crockford base32 skips ambiguous letters, and you can normalize typos (treat I and L as 1, O as 0) when a user types a key in.

Do not store the key. Store SHA-256(key) in KV as the record id, with the metadata next to it: product, max activations, expiry, revoked flag. If your KV leaks, nobody can use it to get working keys. The plaintext key is returned once, at creation. Same idea for buyer emails: store a hash if you need to match them, not the address.

Because the keys carry 100 bits of entropy, guessing is infeasible. A Cloudflare rate-limiting rule on the validate and activate routes is still a cheap extra layer.

2. Validate with signed responses

A plain {"valid": true} from your server has a weakness: anyone can point your app at a fake server that always says yes, by editing a hosts file. Fix that by signing the response.

Workers expose Web Crypto, which supports Ed25519, so no dependencies are needed. The server holds the private key as a secret. Your app ships only the public key. The response looks like this:

{ "valid": true, "reason": "ok", "expires_at": null, "activations_left": 2, "payload": "{...}", "sig": "mV3...", "alg": "ed25519" } 

payload is a JSON string containing the key id, product, instance id, validity, iat and a ttl. sig is a base64url signature over the exact bytes of that string. The client then:

  1. Verifies the signature with the embedded public key.
  2. Checks the key id equals sha256(key), and that the product and instance match what it asked about. Without this, an attacker could replay a valid response for a different key.
  3. Caches the result until iat + ttl, so the app works offline for that window.

HMAC works too, but the secret would have to ship in your app, so for client software prefer Ed25519.

3. Activation limits

Each install generates an instance_id (a random id saved locally, or derived from something stable on the machine). POST /activate records that instance against the key, up to max_activations. POST /deactivate frees the slot, so a user can move to a new computer. Validate takes an optional instance_id and returns not_activated if that machine is not registered.

A useful detail: if max_activations is 0, treat it as unlimited and skip tracking entirely. Then validation needs one KV read and no writes.

4. The free tier, and its honest limits

Validation is a single KV read. Writes only happen on create, first activation per instance, deactivate and revoke. For a small indie product that stays well below KV's free-plan write limit (I use 1,000 writes per day as the working number; check Cloudflare's current limits before relying on it). Edge caching of validate responses for around 60 seconds reduces reads further.

The trade-off is that KV is eventually consistent:

  • A revocation can take up to about 60 seconds to reach every location.
  • Two simultaneous activations from different instances can slip past the limit by one.

For licensing that is acceptable. If you need strict atomicity, use Durable Objects instead.

And the usual caveat: license keys deter casual sharing and let you honor refunds. They are not DRM. Anyone who controls the machine can patch your client.

5. Sales webhooks

Gumroad. Under Settings > Advanced > Ping you can set a URL. Gumroad pings are unsigned, so authenticate with a long random secret in the query string (/webhooks/gumroad?secret=...) over HTTPS. A sale creates a license. A refund, dispute or chargeback revokes it. If you turn on "Generate a unique license key per sale" for the product, the ping carries license_key, and you store the hash of that key, so the buyer gets the key Gumroad already emails them.

Lemon Squeezy. Webhooks are signed: compute an HMAC of the raw body with your signing secret and compare it to the X-Signature header, returning 401 on mismatch. Handle order_created and order_refunded, plus license_key_created if you enable license keys on the variant, so the platform's key becomes the license.

Make both handlers idempotent. Both platforms retry, so index the sale (for example s:gumroad:<sale_id>) and return the existing key on a duplicate rather than creating a second one. That same index lets a refund find the right license.

If a sale carries no platform key, you can derive one deterministically (HMAC of provider and sale id with a server secret) and look it up by sale id. You then have to deliver it to the buyer yourself, because the platform will not know about it.

If you would rather not build it

I packaged all of the above as a kit, so here is the disclosure: I am the maker, and the paid version costs money.

  • Kitebyte License Server Lite is free and MIT licensed: about 70 lines, create and validate keys, optional expiry. Download it at https://kitebyte.dev/license-kit/
  • Kitebyte License Server Pro is $29 one-time at launch (regular price $39): TypeScript source with zero runtime dependencies, about 6 KB gzipped. It adds revoke, lookup and list, activation limits, Ed25519 or HMAC signed responses, Gumroad and Lemon Squeezy webhooks, an admin page, JS, Python and Go client snippets, and a Vitest suite. https://kitebyte.gumroad.com/l/license-server

Known gaps, stated plainly: the Go client was written to spec but has not been compiled yet, and the Python client has not been run against a live server. The Pro license allows unlimited projects of your own or your clients', but not reselling the kit.

The same code runs in production at https://licenses.kitebyte.dev (it powers the paid unlock of my cut-list tool). curl https://licenses.kitebyte.dev/v1/pubkey returns its Ed25519 public key; everything else needs a key or the admin secret.

I would like to hear what you would do differently, especially around offline grace periods and activation handling.