Skip to content
← All posts
· Delta1 Labs LicensingSecurityDeep Dive

Anatomy of a signed license key: why it cannot be forged

A good license key is not a random string checked against a list — it is a signed claims payload the app can verify offline with a public key it ships. Here is what lives inside a signed key, why the private/public split makes it unforgeable, how verification works without a server, and what signing does and does not protect against.

Ask someone how license keys work and you often hear “the app sends the key to a server, the server checks if it’s valid.” That design exists, but it has a hard dependency: no server, no validation. A better design makes the key self-proving — it carries its own claims and a cryptographic signature, and the application verifies it offline with a public key it already ships. Nothing about authenticity depends on a network call. Understanding what lives inside such a key, and why it cannot be forged, is the foundation of every robust licensing scheme.

A key is a signed claims payload

A modern license key is not a random identifier; it is a small set of claims plus a signature over those claims. The claims are the facts the license asserts:

{
  "product": "acme-pro",
  "tier": "enterprise",
  "licensee": "acct_8f21",
  "seats": 25,
  "expiry": "2027-10-04T00:00:00Z",
  "features": ["sso", "audit-log"]
}

On its own, that payload is just data — anyone could type it. What makes it a license is the signature appended to it: a value computed from the payload with a private key that only your license server holds. The key your customer pastes in is the payload and the signature together, encoded into a compact, copy-pasteable form.

The private/public split is the whole trick

The reason the key cannot be forged is asymmetric cryptography. There are two keys, and they are not interchangeable:

  • The private key lives only on your license server. It is the only thing that can produce a valid signature.
  • The public key is embedded in your application, shipped to every customer. It can verify a signature but cannot produce one.

Deriving the private key from the public key is computationally infeasible for the algorithms used (such as Ed25519 or ECDSA on P-256). So an attacker who has your application — and therefore has the public key — still cannot sign anything. They can read the public key all day; it does not help them mint a new key or alter an existing one, because verification will fail the moment a single byte of the payload or signature changes.

Here is the shape of signing on the server and verifying in the app, using Ed25519:

// SERVER (private key never leaves here): sign the canonical claims bytes.
byte[] payload = CanonicalJson(claims);                 // stable byte encoding
byte[] signature = SignEd25519(payload, privateKey);     // 64-byte signature
string licenseKey = Base32(Concat(payload, signature));  // what the customer gets
// APP (ships only the public key): verify before trusting any claim.
var (payload, signature) = SplitBase32(licenseKey);
if (!VerifyEd25519(payload, signature, PublicKey))        // embedded public key
    throw new InvalidLicenseException("signature invalid");

var claims = ParseClaims(payload);
if (claims.Product != "acme-pro") throw new InvalidLicenseException("wrong product");
// expiry, tier, seats, features are now trustworthy — they were signed

The order matters: verify the signature first, then read the claims. A claim you have not verified is attacker-controlled input. Once VerifyEd25519 returns true, every field in the payload is known to be exactly what your server signed — unchanged and authentic.

Serversign(claims, priv)license keyclaims + signaturecopy-pasteable stringApp (embeds public key)1. verify(sig, pub) → ok?2. then trust tier / expiry / seats

Why self-contained beats “check against a list”

Because the claims and signature travel together and the public key is already in the app, verification is entirely local. No server round-trip is needed to know a key is authentic and unmodified — which is what makes true offline and air-gapped activation possible. A server-list design cannot do this: it must reach the server on every check, so an offline machine, a network blip, or a decommissioned validation endpoint all become outages for paying customers.

Self-contained keys also scale trivially. There is no database row to look up per validation and no endpoint to keep online for authenticity; the math is the authority. You may also contact a server periodically — to honor revocation or refresh a lease — but that is an enhancement on top of a key that is already provably genuine, not a prerequisite for trusting it.

What a forged or tampered key looks like

Consider the two things an attacker might try. Altering a claim — bumping tier to enterprise or pushing expiry out a decade — changes the payload bytes, so the signature no longer matches and VerifyEd25519 returns false. Fabricating a whole key requires producing a signature that verifies against your public key, which requires the private key they do not have. Both fail at the same check. This is the tamper-evidence that a random-string-plus-database design lacks: there, the string carries no claims, so the server is the only thing that knows what the key means, and the key itself proves nothing.

Practical details that matter

A few specifics separate a toy from a production scheme:

  • Canonical encoding. Sign a stable byte representation of the claims, and verify over the exact bytes received. If your JSON serializer reorders fields or varies whitespace between sign and verify, valid keys will fail. A canonical form (fixed field order, no incidental whitespace) avoids this.
  • Key rotation. You will eventually want to roll the signing key. Ship the app able to accept more than one public key — the current one plus recent predecessors — so keys signed before a rotation keep verifying. Bake this in from day one; retrofitting it is painful.
  • Modern algorithm, adequate size. Prefer Ed25519 or ECDSA P-256 over legacy choices; they give short signatures and keys with strong security. Avoid rolling your own signing or using a symmetric MAC in the client (see below).
  • Encode for humans where keys are typed. If customers paste keys by hand, a grouped base32 form (XXXX-XXXX-…) with a checksum catches typos before verification even runs.

What signing does not do

Signing is the foundation, not the whole building. Two limits to be clear about. First, a signature proves authenticity, not exclusivity — a signed key is genuine no matter how many people copy it, so signing does nothing to stop sharing. That is the job of activation and device binding, layered on top. Second, never put the signing secret in the client. The entire scheme rests on the signing capability being server-only; if you “simplify” by using a symmetric secret (an HMAC key) embedded in the app so the app can both sign and verify, then anyone who extracts that secret — and it can be extracted — can forge unlimited keys. Asymmetric signing exists precisely so the client holds only the power to verify, never to create.

Put together, a signed license key is a small, self-proving credential: claims your server vouched for, wrapped in a signature only your server could produce and any copy of your app can check without a network. Get that foundation right — verify before you trust, keep the private key server-side, plan for rotation — and everything else in licensing (expiry, entitlements, activation, revocation) is built on ground that cannot be forged out from under you.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.