DEV Community

Cover image for Designing license keys that work offline and die on refund
Digital Alchemyst
Digital Alchemyst

Posted on

Designing license keys that work offline and die on refund

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
Enter fullscreen mode Exit fullscreen mode
  • Crockford Base32 alphabet — no I, L, O, U. A user who types 0 for O or 1 for l still 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');
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. At activation, the app generates a device keypair and calls /v1/licenses/activate with the typed key + device public key.
  2. The server checks the key, the seat count (more below), and signs a lease: {license, device, expires_at} with an Ed25519 private key.
  3. The app stores the lease and verifies it offline on every launch with the baked-in public key. No network. No telemetry.
  4. 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)
Enter fullscreen mode Exit fullscreen mode

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

  1. Design the key for the human. Crockford Base32, forgiving canonicalization, version prefix.
  2. Never store the raw key. HMAC with a pepper for lookup, AEAD ciphertext for owner reveal.
  3. Offline verification, online renewal. The lease TTL is your refund-enforcement latency — pick it consciously.
  4. Seats with self-service release. Punishing hardware upgrades creates refunds, which triggers your own revocation machinery. Poetic, but bad.
  5. Wire revocation to the money, not to an admin panel. If revocation needs a human, it doesn't happen.
  6. 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)

Collapse
 
unitbuilds profile image
UnitBuilds •

Do not follow any external links! DEV.to uses Sloan for automated messages, this is likely phishing.