How to Issue & Verify Offline Licenses for Air-Gapped Apps
If your customers need to run your software on secure, air-gapped machines—or simply without reliable internet—you still need robust licensing. Keymint's Offline Licenses feature lets you issue signed .lic files from the dashboard and verify them purely client-side using Ed25519 signatures.
If you're evaluating platforms with offline verification in mind, see how the options stack up in our Keymint vs Keygen comparison.
In this post we'll cover:
- Why offline licenses matter
- Generating licenses in the dashboard
- Understanding the
.licfile format - Distribution strategies
- Client-side verification steps
- Best practices & tips
1. Why Offline Licenses Matter
You've built great software, but some customers can't—or won't—expose servers to the internet. Typical scenarios include:
- 🔒 Air-gapped networks (military, finance, healthcare)
- 🏢 On-premise deployments behind corporate firewalls
- 🚧 Manufacturing or IoT devices in isolated facilities
Offline licenses give you the same cryptographic security as online API checks, but without the runtime network dependency.
It's also a differentiator most tools skip: only 9 of the 54 popular products in our licensing benchmarks study document offline use on their own pages.
2. Generate an Offline License (Dashboard)
Unlike runtime API calls, offline licenses are created via the Keymint Dashboard:
- Log in → Products → Your Product → Licenses
- Find the license row you want to issue.
- In the Activations column, click the Eye icon (👁️) to open the Offline License modal.
- Machine Code: enter a hardware fingerprint (e.g.
host-ABC123) to lock this file to one device. This is required for all offline licenses. - Custom TTL (seconds): required if the license itself has no expiration date.
- Click Generate Signed License.
Note: If your license record lacks an expiry date, you must supply a
TTLhere or generation will fail.
3. Understanding the .lic File
When you press Generate, your browser downloads a JSON file named like:
license-ABC123-host-ABC123-2025-06-23.licThe file structure looks like this:
{
"signedDate": "2025-06-23T14:12:00.000Z",
"signedKey": "eyJhbGciOiJFZERTQSIsImtpZCI6IjFvUFlVY1ZKZ1ZtQzNfQ1VxTnNacVYwVnZIOXJ4c0o5Z1VWS2ZkYkV6bVUiLCJ0eXAiOiJKV1QifQ.eyJwcm9kdWN0SWQiOiJQ...",
"keyId": "1oPYUcVJgVmC3_CUqNsZqV0VvH9rxsJ9gUVKfdbEzmU",
"publicKeyFingerprint": "1oPYUcVJgVmC3_CUqNsZqV0VvH9rxsJ9gUVKfdbEzmU"
}Field breakdown:
signedDate: issuance timestampsignedKey: EdDSA-signed JWT containing your payload (its protected header includes akid)keyId/publicKeyFingerprint: base64url(SHA-256(SPKI DER)) of the vendor public key used to verify. Included for operator visibility and UI hints.
This format avoids embedding a public key inside the .lic. Instead, verification uses a local Key Registry that maps kid → public key.
4. Distribution Strategies
Choose how your customers receive the .lic file:
- 🌐 Web Download: provide a secure link in your portal.
- ✉️ Email: send as an attachment to IT teams.
- 💾 USB/Media: copy onto removable drives for air-gapped sites.
- 📦 Installer Bundle: package into your installer for seamless setup.
Rename files consistently (e.g. MyApp-OfflineLicense-20250623.lic) and instruct clients not to modify contents.
5. Client-Side Verification (Quick Start)
What you ship with your app:
- A local Key Registry:
Record<kid, PEM>of vendor public keys. Thekidisbase64url(SHA-256(SPKI DER))of the PEM. - A verifier that only accepts keys found in this Key Registry and pins
alg = EdDSA.
Node.js (or Electron/Browser with bundlers)
import { jwtVerify, importSPKI, decodeProtectedHeader } from "jose";
import fs from "fs";
// 1) Load license file
const { signedKey } = JSON.parse(fs.readFileSync("license.lic", "utf8"));
// 2) Resolve trusted public key by `kid` from JWT header
const { kid, alg } = decodeProtectedHeader(signedKey);
if (!kid) throw new Error("Token missing key id (kid)");
if (alg !== "EdDSA") throw new Error("Unexpected alg");
// Your app ships a Key Registry of vendor public keys keyed by fingerprint (kid)
const TRUSTED_PUBLIC_KEYS: Record<string, string> = {
// kid: PEM
"1oPYUcVJgVmC3_CUqNsZqV0VvH9rxsJ9gUVKfdbEzmU": `-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----`,
};
const pem = TRUSTED_PUBLIC_KEYS[kid];
if (!pem) throw new Error(`Unknown key id: ${kid}`);
// 3) Import public key
const edKey = await importSPKI(pem, "EdDSA");
// 4) Verify signature & decode (alg restricted)
const { payload } = await jwtVerify(signedKey, edKey, { algorithms: ["EdDSA"] });
// 5) Enforce expiry and optional machine binding
const now = Math.floor(Date.now() / 1000);
if (typeof payload.exp !== "number" || payload.exp < now) throw new Error("License expired");
if (payload.machineCode && payload.machineCode !== getLocalMachineCode()) throw new Error("Wrong machine");
// ✅ License is valid
console.log("License payload", payload);
function getLocalMachineCode(): string {
// implement your machine fingerprinting
return "host-ABC123";
}Python (PyJWT + cryptography)
import json, time, jwt
from cryptography.hazmat.primitives import serialization
# 1) Load license file
with open("license.lic", "r", encoding="utf-8") as f:
lic = json.load(f)
signedKey = lic["signedKey"]
# 2) Resolve trusted public key by `kid`
hdr = jwt.get_unverified_header(signedKey)
kid = hdr.get("kid")
alg = hdr.get("alg")
if not kid:
raise ValueError("Token missing kid")
if alg != "EdDSA":
raise ValueError("Unexpected alg")
TRUSTED_PUBLIC_KEYS = {
# kid: PEM
"1oPYUcVJgVmC3_CUqNsZqV0VvH9rxsJ9gUVKfdbEzmU": """
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...
-----END PUBLIC KEY-----
""".strip()
}
pem = TRUSTED_PUBLIC_KEYS.get(kid)
if not pem:
raise ValueError(f"Unknown key id: {kid}")
pub_key = serialization.load_pem_public_key(pem.encode("utf-8"))
# 3) Verify signature & decode
payload = jwt.decode(
signedKey,
key=pub_key,
algorithms=["EdDSA"],
options={"require": ["exp"]},
)
# 4) Enforce machine binding (optional)
if payload.get("machineCode") and payload["machineCode"] != get_local_machine_code():
raise ValueError("Wrong machine")
print("License payload", payload)
def get_local_machine_code() -> str:
# implement your machine fingerprinting
return "host-ABC123"Notes:
- The private key is used only by Keymint to sign. Your app verifies with the public key from the Key Registry.
- Do not accept licenses if the
kidis unknown or missing. - Avoid network I/O at verification time; everything needed ships with your app.
6. Best Practices & Tips
- Fail closed: on any error (missing
kid, unknown key, bad signature, wrongalg, expiredexp). - Do not embed public keys: maintain a local Key Registry and only resolve
kid→ PEM from it. - Key rotation: ship new public keys with updates; tokens will carry the correct
kidautomatically. - Filename conventions:
license-<KEY>-<machineCode?>-YYYYMMDD.lic. - Short TTLs: useful for revocable offline licenses (e.g. 7 days).
- Secure storage: lock down file permissions for the
.licon sensitive systems.
By following these steps, you can deliver a secure, offline-capable licensing experience—no network needed at runtime, and cryptographically tamper-resistant with a strict trust model.