License Server v1.25.0 · released 28 Oct 2026release notes →

Offline license verification with public-key signatures

Last updated October 27, 2026

Most license checks are a network call: your app sends the key to a server, and the server says yes or no. That stops working when the customer's machine is offline: a laptop on a plane, a factory network with no internet, a government system that's air-gapped on purpose.

The usual fix is to remember the last answer and trust it for a while. That buys you time, but the app is still taking its own cached word for it, and anything stored on the customer's machine can be edited.

There is a stronger option: a license file the app can check by itself, with no server at all, that can't be edited without the app noticing. This guide explains how that works, what it proves, and where it falls short.

Signing and verifying, in plain terms

The idea is a digital signature, using a pair of keys:

  • A private key, which stays on your license server. It's the only thing that can produce a valid signature.
  • A public key, which you ship inside your app. It can check a signature, but can't make one.

When you issue a license, the server takes the license's details (which product, which tier, when it expires) and signs them with the private key. The app receives the details plus the signature. To check the license, the app runs the signature and the details through the public key. If a single character of the details was changed, by accident or by someone editing the file, the check fails.

Because the public key can only verify, it doesn't need to be secret. Anyone can read it out of your app, and it still doesn't let them create a license. The private key is the one to protect.

What License Server signs

License Server signs licenses with RSA, SHA-256 and PKCS#1 v1.5 padding, using the key in its LICENSE_RSA_PRIVATE_KEY setting. Two endpoints matter:

  • GET /v1/public-key returns the public key. Fetch it once and build it into your app; it isn't meant to be fetched before every check.
  • POST /v1/license-export/offline, with the key in the body as {"key": "..."}, returns a signed license (License Server 1.21.0+; the older GET /v1/export-license/{key}/offline still works, but puts the key in the URL and is deprecated):
{
  "license": {
    "type": "license",
    "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
    "tier": "pro",
    "product_id": "my-product",
    "product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
    "expires_at": "2027-12-31T23:59:59.000Z",
    "limits": {},
    "issued_at": "2025-01-01T00:00:00.000Z",
    "exported_at": "2026-06-01T08:00:03.512Z",
    "livemode": true,
    "paid_through": "2027-12-24T23:59:59.000Z"
  },
  "signature": "base64-encoded-rsa-signature"
}

The signature covers exactly the license object: its type, key, tier, product, expiry and limits and, from License Server 1.20.0, the fields after limits (below). type is always "license": the server also signs software-update manifests with the same key, and the type is what keeps one from passing as a license. The buyer's name or email is deliberately left out, so the file reveals nothing about who bought it. Only an active, unexpired license can be exported; the endpoint refuses others, including (1.20.0+) a sandbox license its product refuses.

From License Server 1.20.0, the file also signs:

  • issued_at, when the license was issued;
  • exported_at, the server's clock when it signed the file;
  • livemode, true for a license from a live storefront purchase and false for a sandbox (test-mode) one, left out when unknown;
  • paid_through, the end of the latest period the buyer paid for with money, left out when there is none.

Files from older servers have none of these, so treat each as optional. Because they're signed, your app can trust them with no server call: it can limit offline use by exported_at (see below), refuse a test purchase's license by rejecting livemode: false, or let a buyer keep what they paid for up to paid_through.

Verifying it in your app

With the JavaScript SDK, @casazium/license-sdk:

import { CasaziumLicenseClient } from '@casazium/license-sdk';

const client = new CasaziumLicenseClient({ publicKey: PUBLIC_KEY_PEM });

const valid = await client.verifySignedFile({ license, signature }, MY_PRODUCT_UUID);

if (!valid) {
  // refuse to run, or fall back to a free mode
}

verifySignedFile() (SDK 0.3.0 and later) checks four things, and returns false if any fails, or if the file is malformed:

  • the signature is valid;
  • it's a license, not a signed update manifest (type);
  • it's for your product (product_uuid) — a genuine license for someone else's product is still a validly signed file;
  • it hasn't expired. A license with expires_at: null never expires.

A file exported from a License Server older than 1.10.0 has no type, so it's refused: export it again.

In other languages

Any language with standard RSA support can verify the same file: check an RSA signature with SHA-256 and PKCS#1 v1.5 padding over the license object, then make the same three checks as the SDK: type is "license", product_uuid is yours, and expires_at is null or in the future. The one detail that trips people up is that the signature covers the license as compact JSON (no spaces) with its fields in the order the server sent them. Verify the object exactly as received: parse it, re-serialize it that way, and don't rebuild it from the fields you know, sort its keys, or round-trip it through a typed struct or class. New fields can appear after limits in later releases, and a rebuilt object fails verification. In Python:

import base64, json

file = json.load(open('license-file.json', encoding='utf-8'))  # the {license, signature} file
license = file['license']
signature = base64.b64decode(file['signature'])  # the file carries it as base64 text
data = json.dumps(license, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
public_key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
# then: license['type'] == 'license', license['product_uuid'] == MY_PRODUCT_UUID,
# and expires_at is None or later than now

The client examples have complete versions for Python, Java and Go.

What offline verification can't do

A signed file proves the license is genuine and unedited. It doesn't know anything that happened after it was signed, and that has consequences:

  • No revocation. If you revoke a license, a copy of the signed file exported before that still verifies, to its own expiry, and a perpetual license's file never expires. Revocation takes effect only when the app checks online and acts on the answer: see the next section.
  • No seat limits. The file isn't tied to a machine, so on its own it can't stop the same file being copied to a second computer. Seat limits need the server: activate each machine online once, then use the signed file for the days the machine is offline.
  • The clock is the customer's. Expiry is checked against the machine's own clock, which its owner controls. A file from License Server 1.20.0 or later signs exported_at, so a clock set well before it (allow some clock difference, say a day) is easy to spot.

These trade-offs are the same for any offline scheme. The difference a signature makes is that the file itself can't be forged or edited.

Combining online and offline checks

Most apps get the best result by using both:

  1. On first run, activate online, which records this machine against the license's seat limit.
  2. Fetch the signed license file and keep it, with the time of this good online check.
  3. On every launch, check online: verifyKey for the license and validate for this machine's seat. After a good answer, save the time and fetch a fresh file, so its expiry and terms follow the server's.
  4. When the server refuses (a 4xx other than 429, or a license for another product) or validate returns false, delete the file and the saved time. Otherwise the customer only has to go offline to undo the refusal. (Server software is different: degrade rather than stop, as Licensing server software explains.)
  5. When there's no answer (offline, a timeout, a 429 or a 5xx), verify the file, and accept it only if the last good online check was recent enough, by a limit you choose, such as 14 days. With a file from License Server 1.20.0 or later, count from its signed exported_at, which the customer can't edit, rather than a time your app saved.

The limit in step 5 is what bounds the revocation gap. Without it, a customer whose license you revoked could keep a perpetual license's file and stay offline for good.

An air-gapped customer can skip the online steps entirely: you export the signed file once and hand it over, and their installation verifies it locally from then on. With no online check, the file's own expiry is the only limit, so give such a license an expiry date and issue a new file at renewal. Licensing server software you ship walks through that case.

Keep the private key safe

Two rules follow from how signing works:

  • Never put the private key in your app. Anyone holding it can make valid licenses for any product, tier and expiry.
  • Back it up, and don't change it casually. Every license file you have ever exported was signed with it. A new key means a new public key in your app, and every existing file stops verifying until it's exported again. On a self-hosted License Server it's the LICENSE_RSA_PRIVATE_KEY setting, generated on first boot if you use the bundle and stored with the rest of its data.

Where to go next

More in Guides, or the full documentation.