<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kitebyte</title>
    <description>The latest articles on DEV Community by Kitebyte (@kitebyte).</description>
    <link>https://dev.to/kitebyte</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4234128%2F16c21219-a87d-43ab-8538-657787cb8440.png</url>
      <title>DEV Community: Kitebyte</title>
      <link>https://dev.to/kitebyte</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kitebyte"/>
    <language>en</language>
    <item>
      <title>A license-key server on Cloudflare Workers + KV, with signed keys and activation limits</title>
      <dc:creator>Kitebyte</dc:creator>
      <pubDate>Tue, 10 Nov 2026 20:27:55 +0000</pubDate>
      <link>https://dev.to/kitebyte/a-license-key-server-on-cloudflare-workers-kv-with-signed-keys-and-activation-limits-ja4</link>
      <guid>https://dev.to/kitebyte/a-license-key-server-on-cloudflare-workers-kv-with-signed-keys-and-activation-limits-ja4</guid>
      <description>&lt;p&gt;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).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server has to do
&lt;/h2&gt;

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

&lt;p&gt;That is a small surface, which suits a Worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Key format and storage
&lt;/h2&gt;

&lt;p&gt;Generate keys from 100 bits of randomness, encoded in Crockford base32, grouped for humans: &lt;code&gt;KB-7K2XM-9QF3T-0ABCD-HJ4WZ&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Do not store the key. Store &lt;code&gt;SHA-256(key)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Validate with signed responses
&lt;/h2&gt;

&lt;p&gt;A plain &lt;code&gt;{"valid": true}&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"activations_left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"{...}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"sig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"mV3..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ed25519"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;HMAC works too, but the secret would have to ship in your app, so for client software prefer Ed25519.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Activation limits
&lt;/h2&gt;

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

&lt;p&gt;A useful detail: if &lt;code&gt;max_activations&lt;/code&gt; is 0, treat it as unlimited and skip tracking entirely. Then validation needs one KV read and no writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The free tier, and its honest limits
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The trade-off is that KV is eventually consistent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A revocation can take up to about 60 seconds to reach every location.&lt;/li&gt;
&lt;li&gt;Two simultaneous activations from different instances can slip past the limit by one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For licensing that is acceptable. If you need strict atomicity, use Durable Objects instead.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Sales webhooks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gumroad.&lt;/strong&gt; Under Settings &amp;gt; Advanced &amp;gt; Ping you can set a URL. Gumroad pings are unsigned, so authenticate with a long random secret in the query string (&lt;code&gt;/webhooks/gumroad?secret=...&lt;/code&gt;) 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 &lt;code&gt;license_key&lt;/code&gt;, and you store the hash of that key, so the buyer gets the key Gumroad already emails them.&lt;/p&gt;

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

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not build it
&lt;/h2&gt;

&lt;p&gt;I packaged all of the above as a kit, so here is the disclosure: I am the maker, and the paid version costs money.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kitebyte License Server Lite&lt;/strong&gt; is free and MIT licensed: about 70 lines, create and validate keys, optional expiry. Download it at &lt;a href="https://kitebyte.dev/license-kit/" rel="noopener noreferrer"&gt;https://kitebyte.dev/license-kit/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kitebyte License Server Pro&lt;/strong&gt; 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. &lt;a href="https://kitebyte.gumroad.com/l/license-server" rel="noopener noreferrer"&gt;https://kitebyte.gumroad.com/l/license-server&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;I would like to hear what you would do differently, especially around offline grace periods and activation handling.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>serverless</category>
      <category>indiehackers</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
