I sell eight small browser tools. There is no backend: no accounts, no database, no license server, no Stripe webhook. The pages are static files on GitHub Pages, and the only "payment processor" is a store that hands out a text key plus an on-chain transfer that the browser checks itself.
The interesting problem in that setup is not taking money. It is knowing that the person pasting a key paid for it, on a machine that has never talked to a server and never will.
This is how I solved it, what it actually protects against, and where it fails.
The constraint
A license check has to run in the buyer's browser, offline, in under a second, without a round-trip. Anything that phones home defeats the point of a static site: a server means uptime, logs, GDPR questions, and a single point that dies with my hosting bill.
So the check must be a pure local function of two things:
- the key the buyer pasted,
- a public key that ships inside the JavaScript.
That is a signature verification, and browsers have had one for years: crypto.subtle.verify.
The key format
TW1-NEWS-K7Q2M4-<86 chars of base64url>
-
TW1— scheme/version tag. Bumping it invalidates every old key at once, which I want to be able to do. -
NEWS— product code, 2–8 chars:AUDIO,TG,DOC,CLIP,NEWS,RADAR,TAX, andALLfor the universal key. -
K7Q2M4— key id, six chars. Enough to find the key in a sales export without making the string look like a serial number. - The rest — an ECDSA P-256 signature over the string
TW1-NEWS-K7Q2M4.
The signature is the raw 64-byte r||s pair, base64url-encoded. Not DER. That detail matters: WebCrypto wants dsaEncoding: "ieee-p1363" (the default in the spec) while OpenSSL and most CLI tools emit DER by default, so a signature that verifies in Node can fail in the browser if you forget to convert.
No expiry, no buyer identity, no order id inside the key. Every field you add is a field you must sign, and every field you sign is a field you cannot change later without reissuing. The product code is in there because it is the one thing the page needs to decide what to unlock.
Signing, offline
A small Node script holds the private key (PEM on disk, never in the repo) and writes one key per line:
// tools/license.mjs (simplified)
import { webcrypto as crypto } from "node:crypto";
import fs from "node:fs";
const { privateKey } = await crypto.subtle.importKey(
"jwk", JSON.parse(fs.readFileSync("signing-key.jwk", "utf8")),
{ name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]
);
async function makeKey(code, kid) {
const payload = `TW1-${code}-${kid}`;
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" }, privateKey,
new TextEncoder().encode(payload)
);
const b64 = Buffer.from(sig).toString("base64url"); // 86 chars, r||s
return `${payload}-${b64}`;
}
200 keys per product is a 20 KB text file. That file goes to the store's auto-delivery as the "product content" — the store does the transaction, the key generation, and the delivery, and I never see the buyer.
Verifying, in the browser
The whole check is 30 lines, and the only shipped secret is the public key:
const PUBLIC_KEY = {
kty: "EC", crv: "P-256",
x: "YVH3fyYkuivpkuKQjUqw02XxX3mO5FadXsxijuK52so",
y: "POqoRl3cMmg1UUxmwpCDBj2q3kVqwuH-EySTT3zcefQ"
};
async function verifyLicenseKey(text, product) {
const clean = text.trim().replace(/\s+/g, "");
const m = /^TW1-([A-Z0-9]{2,8})-([A-Z0-9]{6})-(.+)$/i.exec(clean);
if (!m) return { valid: false, error: "format" };
const code = m[1].toUpperCase();
const payload = `TW1-${code}-${m[2].toUpperCase()}`;
const sig = Uint8Array.from(atob(m[3].replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
if (sig.length !== 64) return { valid: false, error: "signature" };
const key = await crypto.subtle.importKey(
"jwk", PUBLIC_KEY, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]
);
const ok = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" }, key, sig,
new TextEncoder().encode(payload)
);
if (!ok) return { valid: false, error: "signature" };
// Signature is valid; now decide what it unlocks.
if (code !== "ALL" && code !== PRODUCTS[product]) return { valid: false, error: "product" };
return { valid: true, code };
}
Two failure modes are deliberately separated: signature (someone typed or generated a key) and product (a real key for the wrong tool). The UI says which, because "invalid key" for a valid key pasted on the wrong page is the kind of support ticket you cannot afford when there is no support.
Timing: ~5–10 ms for import + verify on an M1, and the promise is cached after the first call. The whole thing works with DevTools offline mode on.
What this actually protects against
Being precise here matters, because offline licensing is usually oversold.
It stops: random strings, edited keys, keys from a leaked list being re-signed, a buyer changing NEWS to ALL by hand (the product code is inside the signed payload), and a "crack" that is a one-line URL parameter.
It does not stop: a determined person reading the JavaScript and deleting the if (!ok) branch. The code runs on their machine; they can patch it. No client-side scheme survives this, and the ones that pretend to (obfuscation, integrity checks calling home) just add a server back.
So the real goal is not DRM. It is making the honest path shorter than the dishonest one: paste a key, get Pro in 10 ms, no email, no signup, no waiting for a human. Piracy of a $7 browser utility was never the threat model; friction was.
I also made this explicit on the pages instead of hiding it. Overselling "unbreakable licensing" to buyers is how you get refund requests with screenshots of your own JS.
Entitlement, not just verification
A valid key is not the same as a stored purchase. After verification, the key is recorded in localStorage as a signed record and the UI flips to Pro. The ALL code grants every product in one write, and each page reads the same record, so one key opens all eight tools — including the pages that were never open during checkout.
Because the key is the entitlement, re-pasting the same key on a new browser works forever, and clearing site data is the only way to lose it. That is a worse UX than an account and a better one than an email loop, and it is honest about what it is.
Where the money actually comes from
Two paths, both ending in the same key:
-
Card / SBP (RU): a Plati.Market listing. The store's auto-delivery hands over a
TW1-…key the second the payment clears. - USDC on Base: the page builds the transfer, the wallet confirms, the page verifies the transaction against the chain in the browser.
The storefront does not hardcode lot ids. A generated shop.js maps each product slug to its live lot id and the rouble total the checkout actually shows, so a price change is one JSON file and a git push — no rebuild, no deploy step:
// assets/shop.js — generated from the seller's stock file
window.SHOP = {
products: { newsflow: { id: 6358143, price_rub: 390.13, price_usd: 12, ... } },
bundle: { id: 6326936, price_rub: 1290.46, price_usd: 14.99, ... }
};
If a lot has no id, the button is hidden instead of pointing at a checkout that was never created. That one rule has saved me from shipping more broken links than any test.
The other half: tools that never leave the tab
The same "no backend" rule shapes the products themselves:
-
Audio Dub Studio pulls the original audio track out of a video page, marks clips by timecode, and exports WAV/MP3 with ID3/BWF tags — the downloader and the clip studio talk over
postMessage, so no file ever leaves the machine. -
Archive Studio DocSearch indexes
.eml/.mboxarchives locally and searches them with an in-memory index; attachments are extracted in a worker. -
Base Tax & Allowance Audit reads approvals with
eth_callagainst Base mainnet — an approval revoked on-chain reads zero, so the report reflects the chain, not a stale indexer.
None of these need a key check to phone home, which is the point: the license scheme and the products share one architectural constraint.
Trade-offs I would not repeat
- No revocation. A leaked key works forever. For $7–15 tools that is an acceptable trade; for anything subscription-shaped it is fatal.
-
No expiry. Same reason. I would add a signed
expfield and reissue if I ever sold a yearly plan. - The product code is coarse. One key = one product. Fine-grained features would need a bitmask inside the payload, and then the key stops being human-readable.
-
localStorageis per-browser. Buyers who switch machines must re-paste the key. I ship the key in the order email for exactly this reason.
Try it
The storefront, all eight tools, and the live prices: https://9xj89gzrtw-hue.github.io/
Downloads (userscript, unpacked extensions, a free 30-chat CSV sample): releases
The Base audit tool with its stdio MCP server: https://github.com/9xj89gzrtw-hue/base-tax-audit
If you are building something similar, the parts worth copying are the format (version tag, product code, key id, signature — nothing else) and the honesty about what the check does not stop. The parts worth skipping are any attempt to make a browser-side check unbreakable.
Top comments (0)