License keys for digital products, done right: generation, storage, and validation
If you sell software, plugins, or downloads, a license key is usually the only thing standing between "paid customer" and "posted on a forum." Most roll-your-own key systems get at least one of these wrong: Keys genera
If you sell software, plugins, or downloads, a license key is usually the only thing standing between "paid customer" and "posted on a forum." Most roll-your-own key systems get at least one of these wrong:
- Keys generated from timestamps, counters, or
Math.random()(guessable) - Keys stored in plaintext (one database leak = every key works forever)
- Validation that can't revoke (a refunded buyer keeps access)
Here's the design I ended up with after building this into a real product.
1. Generation: random, segmented, unambiguous
A key should be pure entropy — no timestamps, no order IDs, no sequence numbers. Anything predictable gets enumerated.
XXXX-XXXX-XXXX-XXXX
- 16 characters from an unambiguous alphabet (no
0/O,1/I/L— buyers type these by hand and will get them wrong) - Generated with a CSPRNG (
crypto.randomBytes/crypto.getRandomValues), neverMath.random() - Segments of 4 so people can read and dictate them
At ~30 unambiguous characters, 16 of them gives you roughly 78 bits of entropy. Nobody is brute-forcing that against a rate-limited endpoint.
Watch out for one edge: hash collisions on insert. They're astronomically unlikely, but a UNIQUE constraint plus a retry loop (say, 3 attempts) turns a theoretical 500-error into a non-event.
2. Storage: hash it like a password
This is the step people skip. If your license_keys table stores the key itself, then anyone who gets read access to your database — a leaked backup, an overly broad analytics export, SQL injection — has every working key.
Store this instead:
-
salt— at least 16 random bytes, unique per key -
key_hash—SHA-256(salt || key), with aUNIQUEindex -
key_prefix— the first segment only (e.g.XXXX), which is non-secret
The plaintext key is shown exactly once, at issuance (emailed to the buyer, printed on the receipt page), and is unrecoverable after that — same as a password reset flow. The prefix exists purely as an index hint: validation looks up candidate rows by prefix, then verifies the hash.
Why SHA-256 and not bcrypt here? Keys are high-entropy random strings, not human passwords — there's no dictionary to slow down. A fast hash with a per-key salt is the right tool; slow hashing buys nothing and costs you on every validation call.
3. Validation: one call, boolean answer
Your buyers' software (or your own checkout) should need exactly one call:
curl -X POST https://your-host/api/licenses/validate \
-H "Content-Type: application/json" \
-d '{"license_key": "XXXX-XXXX-XXXX-XXXX"}'
{ "valid": true }
Keep the response minimal. Don't return why a key failed (revoked vs. never existed vs. mistyped) — that's free information for someone probing your keyspace. {"valid": false} covers all of it.
Normalization matters more than you'd think: uppercase the input and strip dashes/spaces before hashing, because buyers will paste the key in lowercase, with trailing whitespace, or without the separators.
4. Revocation: refunds must actually revoke
The case everyone forgets: a buyer pays, gets a key, then refunds or chargebacks. If the key still validates, your refund policy is an honor system.
Wire revocation into the payment lifecycle: when your payment provider's webhook reports a refund (charge.refunded in Stripe), flip the key's status to revoked in the same handler that revokes the download entitlement. Validation checks status alongside the hash, so the key dies immediately — no cache-clearing, no support ticket.
Same for download links, by the way: the grant should return 404 after a refund, not just the key failing validation.
The short version
- Keys = CSPRNG entropy, segmented, unambiguous characters
- Store salt + hash + prefix, never the key
- Validate by prefix lookup + hash comparison, answer with a bare boolean
- Normalize input before checking
- Revoke on refund via webhook
I built exactly this into FileFreight, a small tool for selling digital downloads with expiring delivery links and license keys — the key design is documented here if you want to see how it behaves from the seller's side.
If you've shipped a license system, I'm curious: did you hash your keys, or are they sitting in plaintext right now?
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.