I sell desktop apps. Desktop users — especially the privacy-minded ones who buy offline-first software — hate two things: subscriptions, and apps that phone home. But if you sell one-time licenses with no server contact at all, you can't revoke a key after a chargeback, and your key ends up on a keygen site within the month.
This is the design I landed on: offline by default, signed leases for trust, revocation that rides the refund webhook. It powers the license system on my marketplace (Grabnite), and the same key can unlock a C#/WPF screensaver or a Rust/Tauri app.
The key itself: designed for humans typing
Keys get read over the phone, typed from a receipt email, OCR'd from screenshots. So the format optimizes for the human, not the machine:
GRB1-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
-
Crockford Base32 alphabet — no
I,L,O,U. A user who types0forOor1forlstill activates: canonicalization folds those before hashing. - 6 groups × 5 chars ≈ 150 bits of entropy — unguessable, but short enough to type.
- A version prefix (
GRB1) so the format can evolve without breaking old keys.
function canonicalizeKey(input: string): string {
return input.toUpperCase()
.replace(/[^0-9A-Z]/g, '') // strip dashes/spaces however mangled
.replace(/O/g, '0')
.replace(/[IL]/g, '1');
}
Storage: the raw key exists only in memory
The database never holds the key. Two derived values do:
-
key_lookup = HMAC-SHA256(canonical_key, SERVER_PEPPER)— the index for activation lookups. The pepper lives only in deployment secrets, so a full DB dump cannot be brute-forced offline into keys. -
key_ciphertext = AES-256-GCM(key)— so the owner's surfaces (receipt email, "reveal key" in the buyer's account) can show the key back. Decrypt-on-demand, never logged.
A leaked database yields no usable licenses. A leaked pepper alone yields nothing either — you need both plus the ciphertext key.
Activation: leases, not phone-homes
The naive options are both bad:
- Validate online every launch → the app breaks on planes and in airgaps, and you've built a tracking beacon.
- Validate once, trust forever → refunds and chargebacks are unenforceable; one leaked key works for eternity.
The middle path is a signed lease:
- At activation, the app generates a device keypair and calls
/v1/licenses/activatewith the typed key + device public key. - The server checks the key, the seat count (more below), and signs a lease:
{license, device, expires_at}with an Ed25519 private key. - The app stores the lease and verifies it offline on every launch with the baked-in public key. No network. No telemetry.
- Near expiry (default: weeks), the app tries a background refresh. Fails silently on a plane — you have until the lease actually runs out, plus a grace window.
The lease lifetime is the whole tuning knob: it is exactly how long a refunded customer keeps working software. I default to 30 days — long enough that a flaky network never bothers a legit user, short enough that a chargeback abuser isn't a permanent customer.
Ed25519 because verification is a few microseconds, implementations exist everywhere I need them (Web Crypto's crypto.subtle in a Tauri webview, NSec/BouncyCastle in .NET, ed25519-dalek in Rust), and keys/signatures are tiny enough to embed in a JSON lease without ceremony.
Seats: the anti-sharing mechanism that isn't hostile
Each license carries a seat_limit (per product — 1 to 5 typically). Activation registers a device; the 4th activation on a 3-seat license gets a clean 409 telling the user which devices are active — and the buyer's account page has a "release device" button. Self-service seat management is the difference between anti-piracy and customer punishment. The person who bought a new laptop is not a pirate.
Revocation rides the payment webhook
The part most home-grown licensing skips: the license must die when the money leaves.
stripe: charge.refunded → entitlement status = 'refunded'
stripe: charge.dispute.closed → entitlement status = 'revoked' (lost chargeback)
The refund handler calls revoke_entitlement; the next lease refresh returns {valid: false} and the app drops to free. No human in the loop. A reconciliation sweep runs on a timer as the backstop for missed webhooks, because webhooks do get missed.
One deliberate detail: every failed validation returns the same uniform {valid:false, reason:'invalid_or_inactive'} — never "key not found" vs "key revoked" vs "seat limit". Distinct errors are an oracle for enumeration; uniform errors cost support a little and attackers a lot. (Rate limiting per-IP and per-key on top, and those limiters fail open — a rate-limit outage must never lock paying customers out.)
The security honesty section
Client-side enforcement is a speed bump, not a wall. Anyone with a debugger can patch is_pro in any desktop app — DRM vendors with hundred-million-dollar budgets can't stop that, and neither can you. The design goals are calibrated accordingly:
- casual sharing → blocked by seats
- keygens → blocked by server-side keyspace (nothing to reverse: valid keys exist only in the DB)
- refund abuse → blocked by lease expiry
- a determined cracker → not your customer anyway; spend those hours on the product
What I'd tell you to steal
- Design the key for the human. Crockford Base32, forgiving canonicalization, version prefix.
- Never store the raw key. HMAC with a pepper for lookup, AEAD ciphertext for owner reveal.
- Offline verification, online renewal. The lease TTL is your refund-enforcement latency — pick it consciously.
- Seats with self-service release. Punishing hardware upgrades creates refunds, which triggers your own revocation machinery. Poetic, but bad.
- Wire revocation to the money, not to an admin panel. If revocation needs a human, it doesn't happen.
- Uniform failure responses. Your error messages are an API for attackers; make them boring.
This isn't a design doc — the system is live. It sells my own Matrix screensaver on grabnite.com, where a buyer gets a GRB1-… key in their receipt email and types it into the app's License tab; the lease verifies offline from then on, and a refund kills the key at the next refresh. More of my desktop apps (a Telegram-archive search tool, a local-first photo organizer, a markdown notepad) are joining the same store at half their Microsoft Store price — that experiment is the subject of the next post.
Questions about the design — especially the lease-TTL tradeoff and the .NET/Rust verification split — very welcome.
Top comments (2)
Do not follow any external links! DEV.to uses Sloan for automated messages, this is likely phishing.