Python
License key activation for Python desktop apps and command line tools: activate keys, verify Ed25519 signed tokens offline and handle revoked licenses.
This guide adds license keys to a Python desktop app or command line tool. The customer enters a key once, the app activates it against your Keygate server, and from then on it starts offline from a signed token. The module works the same with Tkinter, PySide, PyQt or no interface at all. For the concepts behind each step, see Activating licenses in your app.
Dependencies
The standard library covers the network calls and storage. Checking the Ed25519 signature needs one package:
pip install cryptographyThe module below works on Python 3.10 and later, on Windows, macOS and Linux.
The license module
Fill in your server address and the public key from /api/v1/license/pubkey, and change APP_NAME to your app's folder name. The device identifier is the operating system's machine ID, so it stays the same across restarts and reinstalls.
import base64
import hashlib
import json
import os
import platform
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
SERVER = "https://licenses.example.com"
PUBLIC_KEY_HEX = "3b6a27bc..." # from /api/v1/license/pubkey
APP_NAME = "MyApp"
OFFLINE_DAYS = 7 # how long to keep working when the server cannot be reached
PUBLIC_KEY = Ed25519PublicKey.from_public_bytes(bytes.fromhex(PUBLIC_KEY_HEX))
def _machine_id() -> str:
if sys.platform == "win32":
import winreg
path = r"SOFTWARE\Microsoft\Cryptography"
flags = winreg.KEY_READ | winreg.KEY_WOW64_64KEY
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, flags) as key:
return winreg.QueryValueEx(key, "MachineGuid")[0]
if sys.platform == "darwin":
out = subprocess.run(
["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], capture_output=True, text=True
).stdout
return out.split('"IOPlatformUUID" = "')[1].split('"')[0]
return Path("/etc/machine-id").read_text().strip()
# The OS machine ID, hashed with SHA256, so it is stable but not readable.
IDENTIFIER = hashlib.sha256(_machine_id().encode()).hexdigest()
def _data_dir() -> Path:
if sys.platform == "win32":
return Path(os.environ["APPDATA"]) / APP_NAME
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / APP_NAME
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / APP_NAME
FILE = _data_dir() / "license.json"
class LicenseError(Exception):
def __init__(self, status: int, code: str):
super().__init__(code)
self.status = status # HTTP status, 0 when the server could not be reached
self.code = code
def _b64url(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def read_token(token: str) -> dict | None:
try:
payload, signature = token.split(".")
PUBLIC_KEY.verify(_b64url(signature), payload.encode())
claims = json.loads(_b64url(payload))
except (ValueError, InvalidSignature):
return None
return claims if claims.get("did") == IDENTIFIER else None
def _call(action: str, license_key: str, **extra) -> str:
body = json.dumps({"license_key": license_key, "identifier": IDENTIFIER, **extra}).encode()
request = urllib.request.Request(
f"{SERVER}/api/v1/license/{action}",
data=body,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=15) as res:
return json.load(res)["data"]["token"]
except urllib.error.HTTPError as e:
raise LicenseError(e.code, json.load(e)["error"]["code"]) from None
except (urllib.error.URLError, TimeoutError):
raise LicenseError(0, "OFFLINE") from None
def _save(license_key: str, token: str) -> None:
FILE.parent.mkdir(parents=True, exist_ok=True)
FILE.write_text(json.dumps({"license_key": license_key, "token": token}))
def activate(license_key: str) -> dict:
token = _call("activate", license_key, label=platform.node())
claims = read_token(token)
if claims is None:
raise LicenseError(0, "INVALID_TOKEN")
_save(license_key, token)
return claims
def check() -> dict | None:
"""Returns the license claims, or None when the app is not licensed."""
try:
saved = json.loads(FILE.read_text())
except (OSError, ValueError):
return None
claims = read_token(saved["token"])
now = time.time()
if claims and claims["exp"] > now:
return claims
try:
token = _call("verify", saved["license_key"])
except LicenseError as e:
if e.status == 404:
FILE.unlink(missing_ok=True)
return None
# No connection: trust the last good token for a few more days.
return claims if claims and claims["exp"] + OFFLINE_DAYS * 86400 > now else None
_save(saved["license_key"], token)
return read_token(token)check() works without a network connection as long as the saved token is fresh. When it is due, the module asks the server again and saves the new token. If the server says the license no longer works on this device, the saved key is removed; if the server cannot be reached, the app keeps working for OFFLINE_DAYS longer.
Using it
Check the license on startup, before the main window or command runs:
import license
claims = license.check()
if claims is None:
key = ask_for_key() # your dialog or input()
try:
claims = license.activate(key)
except license.LicenseError as e:
show_error(e.code) # for example ACTIVATION_LIMIT, or OFFLINELicenseError.code is the Keygate error code, or OFFLINE when the server cannot be reached. The error table lists what to tell the customer for each one. claims["ftr"] holds the plan's features, so the app can turn parts of itself on and off. See Features and usage limits.
Before you ship
- Test with a real license from your dashboard, including what happens when you deactivate the device or revoke the license.
- Give customers a way to deactivate from the app, so they can move to a new computer without writing to you.
- Python source is easy to read once installed. The check keeps honest customers honest; it is not meant to stop someone determined to remove it.
Last updated October 4, 2026