Skip to main content
Guides

Offline License Verification in Python with Signed License Files

By Mithun··9 min read
Quick Answer

Sign each license on your machine with an Ed25519 *private* key, and verify it in the app with the *public* key baked into the binary. The app can check a license fully offline — no server call — yet can't forge one, because it never holds the private key. Any edit to the license (a later expiry, an extra paid feature) breaks the signature, and you add an expires date and a machine fingerprint to the signed payload for expiry and node-locking. Do NOT store a list of valid keys or a shared secret in the app — anyone can read those out of the code. I tested the full flow below (genuine license passes; tampering fails with signature invalid). For a production setup with key management, revocation and HWID handled for you, see Licers.

Plenty of Python apps ship without internet access — desktop tools, on-prem software, air-gapped installs — so "call the license server" isn't an option. The offline answer is a signed license file: you sign each license with a private key you keep secret, and the app verifies it with a public key it already has. This post builds that end to end in Python with Ed25519, then *tests* it — issuing a real license, verifying it offline, and watching every tampering attempt fail. I'll also cover machine binding, clock-rollback, and the honest limits of any check that runs on the user's own computer.

Why you can't just check a key inside the app

The instinct is to embed a list of valid keys, or a secret, and compare against it. That doesn't hold, because the check runs on the customer's machine: whatever the code can read, the customer can read too. A .pyc decompiles back to source (try it in our .pyc Decompiler), and even a PyInstaller .exe just bundles that bytecode. So an embedded key list is right there in the clear, and a hard-coded secret can be lifted and used to mint unlimited keys.

A shared secret in the client is the classic mistake: if the same secret both signs and verifies, shipping it to verify means shipping the ability to forge. Offline licensing needs asymmetric (public-key) crypto so the app can verify without being able to sign.

Public-key signing: verify without being able to forge

With a signature scheme like Ed25519, you generate a keypair once. The private key signs licenses and never leaves your build machine; the public key goes into the app and can only *verify*. A customer holding the app has the public key — which lets them check a license but gives them no way to produce a new valid one. You do this once:

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
import base64
priv = Ed25519PrivateKey.generate()
pub = priv.public_key()
raw = lambda k, enc, fmt: base64.b64encode(k).decode()
priv_b = priv.private_bytes(serialization.Encoding.Raw,
serialization.PrivateFormat.Raw, serialization.NoEncryption())
pub_b = pub.public_bytes(serialization.Encoding.Raw,
serialization.PublicFormat.Raw)
print("PUBLIC (ship in the app):", base64.b64encode(pub_b).decode())
print("PRIVATE (keep secret): ", base64.b64encode(priv_b).decode())
keygen.py — run once; keep the private key offline
PUBLIC (ship in the app): yIqRmzs2cYrVTIkEexQONvKCgnJ+7MFj01Na05DkmSg=
PRIVATE (keep secret): dL8w8QebjFl5... # NEVER goes in the app
Real output (yours will differ — keys are random)

Ed25519 keys are tiny (32 bytes) and signatures are 64 bytes, so a signed license stays small and copy-pasteable. It's provided by the well-maintained cryptography package (pip install cryptography).

Issue a signed license

A license is just a small JSON payload — who it's for, what product, when it expires, which features, and which machine — signed as one canonical byte string. Sorting the keys and using a fixed separator matters: the signer and the verifier must serialize the payload identically, or the signature won't match.

import base64, json
from datetime import date, timedelta
def canonical(payload): # identical bytes on both sides
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
def issue(priv, *, customer, product, days, features, machine):
payload = {
"customer": customer, "product": product,
"issued": date.today().isoformat(),
"expires": (date.today() + timedelta(days=days)).isoformat(),
"features": features, "machine": machine,
}
sig = priv.sign(canonical(payload))
return {"license": payload, "sig": base64.b64encode(sig).decode()}
lic = issue(priv, customer="acme@example.com", product="MyApp Pro",
days=365, features=["export", "api"], machine=MACHINE)
issue.py — build and sign a license (developer side)
{
"license": {
"customer": "acme@example.com",
"product": "MyApp Pro",
"issued": "2026-09-01",
"expires": "2027-09-01",
"features": ["export", "api"],
"machine": "691e02a09744f128"
},
"sig": "oewlDFJ9VjKR9CvrFALr+Xtp/kiTAZGctxcahyCBLWLmOK4iES+EMO0ImNurD4Db6+tVy3uMOB2p18cvQydrDA=="
}
The signed license.json you send the customer (real output)

Verify it offline in the app

The app carries only the public key. It re-serializes the payload the same way, checks the signature, then applies the business rules — expiry and machine match. No network at any point:

import base64, json
from datetime import date
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
PUBLIC_KEY = "yIqRmzs2cYrVTIkEexQONvKCgnJ+7MFj01Na05DkmSg=" # baked in
def verify(lic, machine):
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(PUBLIC_KEY))
body = json.dumps(lic["license"], sort_keys=True,
separators=(",", ":")).encode()
try:
pub.verify(base64.b64decode(lic["sig"]), body)
except InvalidSignature:
return False, "signature invalid - tampered or wrong key"
p = lic["license"]
if date.fromisoformat(p["expires"]) < date.today():
return False, f"expired on {p['expires']}"
if p["machine"] != machine:
return False, "not licensed for this machine"
return True, f"valid: {p['customer']} / {p['product']}"
verify.py — runs inside the shipped app

What tampering looks like (tested)

This is the real output from running the verifier against a genuine license and four attacks — all offline. Editing any field, even by one character, invalidates the Ed25519 signature; the business rules catch the rest:

genuine, right machine : (True, 'valid: acme@example.com / MyApp Pro')
tampered expiry -> 2099 : (False, 'signature invalid - tampered or wrong key')
tampered features +admin: (False, 'signature invalid - tampered or wrong key')
copied to another PC : (False, 'not licensed for this machine')
expired license : (False, 'expired on 2026-08-31')
verify() results — real run

A customer can read the whole license — it isn't secret — but they can't change a single field without the private key, and they can't move it to another machine. That's the core guarantee a signed license file gives you, with zero server calls.

Node-locking: bind a license to one machine

The machine field above is a hardware fingerprint. Because it's inside the *signed* payload, a customer can't edit it to match a second computer. A demo fingerprint from the network MAC is enough to show the idea; a production one blends several stable identifiers so swapping one part doesn't defeat it:

import hashlib, subprocess, uuid, platform
def machine_fingerprint():
parts = [
str(uuid.getnode()), # MAC-derived node id
platform.machine(), # CPU arch
platform.node(), # hostname
]
# On Windows you might add the volume serial; on Linux /etc/machine-id.
return hashlib.sha256("|".join(parts).encode()).hexdigest()[:16]
A more robust machine fingerprint

Blend a few identifiers but not too many — bind to so much hardware that a RAM upgrade breaks the license and you'll drown in support tickets. Two or three stable signals is the usual sweet spot, and give customers a self-service way to move a license.

Preventing clock rollback

Offline expiry has one obvious hole: the app trusts the system clock, and a user can set the date back to before the expiry. You can't fully close this without a server, but you can make it painful. On each run, record the latest date you've ever seen (in a location the app controls) and refuse to run if the clock is now *earlier* than that — so time can only move forward:

import json, os
from datetime import date
STATE = os.path.expanduser("~/.myapp/last_seen")
def clock_ok():
today = date.today()
seen = None
if os.path.exists(STATE):
seen = date.fromisoformat(json.load(open(STATE))["last"])
if seen and today < seen:
return False # clock was moved backwards
os.makedirs(os.path.dirname(STATE), exist_ok=True)
json.dump({"last": max(today, seen or today).isoformat()}, open(STATE, "w"))
return True
Monotonic time check — reject a clock set backwards

It's not bulletproof — deleting the state file resets it — but combined with a signed expires date it stops the trivial "just change the clock" bypass, which is what most casual sharing relies on.

The honest limit — and how to harden it

Be clear-eyed about the ceiling: any check that runs on the user's machine can, in principle, be patched out. A determined attacker can decompile the app and make verify() always return True. Offline licensing raises the effort a lot (they can't forge a license, only crack one binary) but it can't make local code unbreakable. Three things push the bar much higher:

  1. Tie real functionality to the license, not just a boolean. Instead of if verify(): unlock(), derive a key from the signed license and use it to *decrypt* the feature's code or data at runtime. Now patching the check past True doesn't help — without a genuine license there's no key, so the feature stays encrypted.
  2. Obfuscate the verification client so the verify() call, the public key, and the decrypt step aren't sitting in readable form for someone to find and patch. Run your script through the free Python Obfuscator before packaging.
  3. Keep crown-jewel logic off the client entirely. The one thing an offline check can never do is stop a fully local crack; for the parts that matter most, do the work on a server you control. The online complement to this guide covers server-verified licensing and where to draw the line.

Doing this in production

Hand-rolling Ed25519 is a good way to *understand* offline licensing, but in production you also need key storage, license issuance, revocation lists, trial handling, HWID management and a way to move a license between machines — all the plumbing around the signature. That's what Licers provides: signed license files with the same public-key model shown here, hardware binding, and both offline and server-checked validation, so you drop in a few lines instead of maintaining a crypto and key-management stack. Pair it with obfuscating your build and you've covered both halves — a license nobody can forge, and a client that's hard to patch.

Harden the client that checks the license

A local check can be patched out — obfuscate your verification code free so the public key, the check and the decrypt step aren't sitting in readable form in the shipped build.

Open the Python Obfuscator

Free tools mentioned here

Related guides

Frequently asked questions

How do I verify a license without an internet connection in Python?

Use a public-key signature. Sign each license file on your machine with an Ed25519 private key, embed the matching public key in the app, and have the app verify the signature locally with the cryptography package. No server is contacted — the math guarantees the license wasn't altered. Add an expiry date and a machine fingerprint inside the signed payload for expiration and node-locking.

Why not just store valid license keys in the app?

Because the app runs on the customer's machine, so anything it stores is readable — a .pyc decompiles to source and a PyInstaller .exe just bundles that bytecode. An embedded key list or a shared secret can be extracted and reused to mint unlimited licenses. Public-key signing avoids this: the app holds only the public key, which can verify a license but cannot forge one.

Can an offline license be copied to another computer?

Not if you node-lock it. Include a hardware fingerprint (derived from stable identifiers like the MAC, CPU and hostname) inside the signed payload, and check it at runtime. Because the fingerprint is part of what's signed, a customer can't change it to match a second machine without invalidating the signature.

How do I stop users setting the clock back to dodge expiry?

You can't fully prevent it offline, but record the latest date the app has ever seen and refuse to run if the current date is earlier — so time can only move forward. Combined with a signed expiry date, this blocks the casual 'just change the system clock' bypass. For guaranteed expiry you need an occasional server check.

Is offline license verification unbreakable?

No local check is. A determined attacker can decompile the app and patch the verification to always pass. Offline signing still helps a lot — they can't forge licenses, only crack one binary — and you raise the bar much higher by deriving a decryption key from the license (so patching the check doesn't unlock features), obfuscating the client, and keeping crown-jewel logic on a server.

Keep reading