Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

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), never Math.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 a UNIQUE index
  • 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

  1. Keys = CSPRNG entropy, segmented, unambiguous characters
  2. Store salt + hash + prefix, never the key
  3. Validate by prefix lookup + hash comparison, answer with a bare boolean
  4. Normalize input before checking
  5. 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?

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.