Most SaaS products call home to validate licenses: the client sends an identifier to your server, the server checks the subscription status, and returns a valid/invalid response. This works until it doesn't — your server goes down, the customer's network is restricted, or you're deploying to an air-gapped government or healthcare environment that cannot make outbound HTTPS requests. Cryptographically signed licenses solve this by embedding everything needed for validation inside the key itself, with the mathematics of asymmetric cryptography ensuring it cannot be forged.
Why offline validation matters
The use cases for offline license validation are more common than you might expect:
- Air-gapped deployments: Government, defense, and classified environments frequently prohibit outbound internet access. Your software must validate without a network call.
- No network dependency: Even for connected environments, removing the license server as a dependency improves reliability. A license server outage should not prevent your customers from using software they paid for.
- On-premise installations: Enterprise software deployed on customer infrastructure needs to function in data centers with strict egress firewall rules.
- Embedded devices: Industrial equipment running your software may have no network connectivity at all.
Ed25519: why this algorithm
Ed25519 (Edwards-curve Digital Signature Algorithm using Curve25519) is the right choice for license signing:
- Small keys: 32-byte private key, 32-byte public key. Compact keys mean compact license payloads.
- Small signatures: 64 bytes. An Ed25519 signature embedded in a license key adds only ~88 characters when base64-encoded.
- Fast: Ed25519 signing and verification are significantly faster than RSA-2048 or ECDSA P-256.
- No random number needed for signing (unlike ECDSA): a deterministic algorithm means you cannot accidentally reuse a nonce and leak the private key.
- Well-audited implementations in every language.
License payload design
The license payload contains everything the client needs to enforce your licensing terms without phoning home:
// License payload schema
{
"license_id": "lic_7f3a9b2c", // unique identifier for this license
"product_id": "bastionary-agent", // which product this covers
"customer_id": "cust_abc123", // your internal customer ID
"machine_id": "sha256:a1b2c3...", // hardware fingerprint (optional)
"plan": "enterprise", // tier / plan name
"features": ["sso", "scim", "audit_log"], // feature list
"issued_at": "2025-06-30T00:00:00Z",
"expires_at": "2026-06-30T00:00:00Z",
"version": 1 // schema version for forward compat
}
Signing a license (server-side)
#!/usr/bin/env python3
import json, base64, datetime
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey, Ed25519PublicKey
)
from cryptography.hazmat.primitives.serialization import (
Encoding, PrivateFormat, PublicFormat, NoEncryption,
load_pem_private_key
)
# Generate key pair once — store private key in secrets manager
def generate_keypair():
private_key = Ed25519PrivateKey.generate()
private_pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
public_pem = private_key.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
return private_pem, public_pem
def issue_license(private_key_pem: bytes, payload: dict) -> str:
"""
Returns a license key as: base64(payload) + "." + base64(signature)
"""
private_key = load_pem_private_key(private_key_pem, password=None)
payload_bytes = json.dumps(payload, separators=(',', ':')).encode('utf-8')
payload_b64 = base64.urlsafe_b64encode(payload_bytes).rstrip(b'=').decode()
signature = private_key.sign(payload_bytes)
sig_b64 = base64.urlsafe_b64encode(signature).decode()
return f"{payload_b64}.{sig_b64}"
# Example
private_pem = open('license_signing_key.pem', 'rb').read()
license = issue_license(private_pem, {
"license_id": "lic_7f3a9b2c",
"product_id": "my-product",
"customer_id": "cust_abc123",
"plan": "enterprise",
"features": ["sso", "scim"],
"issued_at": "2025-06-30T00:00:00Z",
"expires_at": "2026-06-30T00:00:00Z",
"version": 1
})
print(license)
# eyJsaWNlbnNlX2lkIjoibGljXzd...gkNjI3QUJDR0Nm...
Verifying a license (client-side)
The public key is embedded in your application binary. The client splits the license key on the period, decodes both parts, and verifies the signature. No network call, no shared secret.
#!/usr/bin/env python3
import json, base64, datetime
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.exceptions import InvalidSignature
# This public key is embedded in your distributed binary
PUBLIC_KEY_PEM = b"""-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...your_public_key_here...
-----END PUBLIC KEY-----"""
class LicenseVerificationError(Exception):
pass
def verify_license(license_key: str) -> dict:
"""
Verifies and returns the license payload.
Raises LicenseVerificationError on any failure.
"""
try:
payload_b64, sig_b64 = license_key.rsplit('.', 1)
except ValueError:
raise LicenseVerificationError("Malformed license key")
# Restore base64url padding
payload_bytes = base64.urlsafe_b64decode(payload_b64 + '==')
signature = base64.urlsafe_b64decode(sig_b64 + '==')
# Verify signature
public_key = load_pem_public_key(PUBLIC_KEY_PEM)
try:
public_key.verify(signature, payload_bytes)
except InvalidSignature:
raise LicenseVerificationError("Invalid license signature")
payload = json.loads(payload_bytes)
# Check expiry
expires_at = datetime.datetime.fromisoformat(
payload['expires_at'].replace('Z', '+00:00')
)
if datetime.datetime.now(datetime.timezone.utc) > expires_at:
raise LicenseVerificationError(f"License expired on {payload['expires_at']}")
return payload
def has_feature(license_payload: dict, feature: str) -> bool:
return feature in license_payload.get('features', [])
# Usage
try:
payload = verify_license(license_key_from_config)
if has_feature(payload, 'sso'):
enable_sso()
except LicenseVerificationError as e:
show_license_error(str(e))
disable_premium_features()
Machine binding
Machine binding ties a license to specific hardware, preventing a single license from being used on unlimited machines. The machine fingerprint is derived from stable hardware identifiers: CPU serial (on Linux, /proc/cpuinfo), motherboard UUID (/sys/class/dmi/id/board_serial), or on macOS, the hardware UUID from IOKit. Hash the collected identifiers together with SHA-256.
import hashlib, subprocess, platform
def get_machine_id() -> str:
identifiers = []
if platform.system() == 'Linux':
try:
with open('/etc/machine-id') as f:
identifiers.append(f.read().strip())
except FileNotFoundError:
pass
try:
result = subprocess.run(
['cat', '/sys/class/dmi/id/board_serial'],
capture_output=True, text=True
)
identifiers.append(result.stdout.strip())
except Exception:
pass
elif platform.system() == 'Darwin':
result = subprocess.run(
['ioreg', '-rd1', '-c', 'IOPlatformExpertDevice'],
capture_output=True, text=True
)
for line in result.stdout.splitlines():
if 'IOPlatformUUID' in line:
identifiers.append(line.split('"')[-2])
combined = ':'.join(sorted(identifiers))
return 'sha256:' + hashlib.sha256(combined.encode()).hexdigest()[:16]
Key rotation and revocation
The limitation of offline licenses is that revocation requires a network call or the license to expire naturally. For most use cases, short-to-medium expiry windows (1 year for annual billing, 30 days for monthly) are acceptable — the license becomes self-invalidating. For immediate revocation needs (fraud, chargeback), you can include a revocation check as a soft requirement: the application checks a revocation list URL on startup when network is available, and falls back to allowing operation when offline.
To rotate the signing key, generate a new key pair and sign new licenses with the new private key. Embed multiple public keys in the application binary, indexed by a version number in the license payload. Licenses issued with the old key continue to validate with the old public key until they expire.