Software license key formats: from serial numbers to cryptographically signed payloads

Software licensing has a spectrum from trivially bypassable to genuinely robust. Understanding where each approach sits on that spectrum — and what it costs in complexity — lets you choose the right level of protection for your use case. Most paid desktop software does not need the same protection level as a defense contractor's build tools. Here is the progression from basic to cryptographically strong.

Old-school serial numbers

Classic serial numbers like XXXX-XXXX-XXXX-XXXX use a checksum algorithm to validate the key format. The validation runs entirely on the client with a secret algorithm embedded in the binary. This was the standard approach in the 1990s and early 2000s — and it failed comprehensively. Key generators ("keygens") extracted or reversed the validation algorithm and generated unlimited valid keys. If the validation algorithm is in the client, it can be reversed or patched out.

Some schemes used a simple formula: treat the key as a base-36 number, compute modulo some prime, check the result equals a specific value. Others used checksums across character groups. None were meaningfully secure once anyone motivated looked at the binary.

HMAC-based license keys

HMAC keys are a significant improvement. The key is generated by computing an HMAC over license attributes (customer ID, product ID, feature flags, expiry date) using a server-side secret. Validation recomputes the HMAC and compares. Anyone who knows the HMAC secret can generate valid keys, but the secret never leaves the server — the client only validates.

Wait: if validation requires the HMAC secret, how does the client validate offline? It cannot. HMAC-based keys require either an online validation call or shipping the HMAC secret in the binary (which recreates the original problem). For offline-capable software, you need asymmetric cryptography.

// Server-side: generating an HMAC license key
import { createHmac } from 'crypto';

function generateHmacLicenseKey(params: {
  customerId: string;
  productId: string;
  plan: string;
  expiresAt: number;  // Unix timestamp
  secret: string;
}): string {
  const payload = [
    params.customerId,
    params.productId,
    params.plan,
    params.expiresAt.toString()
  ].join(':');

  const hmac = createHmac('sha256', params.secret)
    .update(payload)
    .digest('hex')
    .slice(0, 16)
    .toUpperCase();

  // Format as PROD-PLAN-EXPIRY-HMAC
  const expHex = params.expiresAt.toString(16).toUpperCase().padStart(8, '0');
  return `${params.productId}-${params.plan.toUpperCase()}-${expHex}-${hmac}`;
}

// Output: API-PRO-63F5A200-4A2B8C9D1E3F0A1B

Ed25519 signed JSON payloads

Asymmetric signing solves the offline validation problem. The server signs a license payload with its private key. The client validates the signature using the embedded public key. Since the public key can only verify signatures and cannot create them, embedding it in the binary does not allow generating new keys.

Ed25519 is the right choice for new implementations: small key size (32 bytes), fast signing and verification, and no padding oracle vulnerabilities. The license payload is a JSON object encoding all the capabilities the license grants.

// Server: issue a signed license
import { generateKeyPairSync, sign } from 'crypto';

// One-time: generate your signing keypair
// const { privateKey, publicKey } = generateKeyPairSync('ed25519');

interface LicensePayload {
  license_id: string;
  customer_id: string;
  customer_email: string;
  product: string;
  plan: string;
  features: string[];
  max_seats: number;
  issued_at: number;
  expires_at: number;
}

function issueLicense(payload: LicensePayload, privateKeyPem: string): string {
  const payloadJson = JSON.stringify(payload);
  const payloadB64 = Buffer.from(payloadJson).toString('base64url');

  const signature = sign(
    null,  // Ed25519 does not use a hash algorithm parameter
    Buffer.from(payloadJson),
    privateKeyPem
  );
  const signatureB64 = signature.toString('base64url');

  // License = base64url(payload).base64url(signature)
  return `${payloadB64}.${signatureB64}`;
}

// Client: validate a signed license (public key embedded in binary)
function validateLicense(licenseString: string, publicKeyPem: string): LicensePayload {
  const [payloadB64, signatureB64] = licenseString.split('.');
  if (!payloadB64 || !signatureB64) throw new Error('malformed license');

  const payloadBuffer = Buffer.from(payloadB64, 'base64url');
  const signatureBuffer = Buffer.from(signatureB64, 'base64url');

  const { verify } = require('crypto');
  const valid = verify(null, payloadBuffer, publicKeyPem, signatureBuffer);
  if (!valid) throw new Error('invalid license signature');

  const payload = JSON.parse(payloadBuffer.toString()) as LicensePayload;

  if (Date.now() / 1000 > payload.expires_at) {
    throw new Error('license expired');
  }

  return payload;
}

Machine binding

A signed license is valid anywhere it is presented. Machine binding ties the license to a specific hardware fingerprint, so a shared license cannot be used on more machines than purchased. The fingerprint is typically a hash of stable hardware identifiers — CPU ID, motherboard serial, network interface MAC address. The fingerprint is submitted to the license server during activation, and subsequent validations verify that the license is being used on the same machine.

// Machine fingerprint (cross-platform)
import { networkInterfaces } from 'os';
import { createHash } from 'crypto';

function getMachineFingerprint(): string {
  const components: string[] = [];

  // Network interface MAC addresses (sort for stability)
  const nets = networkInterfaces();
  const macs: string[] = [];
  for (const ifaces of Object.values(nets)) {
    for (const iface of ifaces ?? []) {
      if (!iface.internal && iface.mac !== '00:00:00:00:00:00') {
        macs.push(iface.mac);
      }
    }
  }
  macs.sort();
  components.push(macs.join(','));

  // On Linux: machine-id
  try {
    const machineId = require('fs')
      .readFileSync('/etc/machine-id', 'utf8')
      .trim();
    components.push(machineId);
  } catch { /* not on Linux */ }

  return createHash('sha256').update(components.join('|')).digest('hex');
}

// License activation — ties this license to this machine
async function activateLicense(licenseKey: string): Promise<void> {
  const fingerprint = getMachineFingerprint();
  const response = await fetch('https://licensing.example.com/activate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ license_key: licenseKey, machine_fingerprint: fingerprint })
  });

  if (!response.ok) {
    const err = await response.json();
    throw new Error(err.message || 'Activation failed');
  }

  const { signed_license } = await response.json();
  // Store signed license locally for offline validation
  await storeLicense(signed_license);
}
Hardware fingerprints change when users replace hardware. Always allow a small number of reactivations per license (typically 3–5) and provide a support path for users who exceed that limit. Being too strict with machine binding drives users toward pirated versions with the binding removed.

Online check vs offline grace period

For subscription licenses, you need periodic online validation to catch revoked or expired licenses. The common pattern is: validate offline using the signed license file on every launch, and perform an online check once per day in the background. If the online check fails (network unavailable), extend the grace period for up to 7 days before switching to degraded mode. This handles enterprise environments where machines may not have direct internet access while not allowing unlimited offline use of revoked licenses.

← Back to blog Try Bastionary free →