Documentation menu▾

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:

bash
pip install cryptography

The 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.

license.py
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:

python
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 OFFLINE

LicenseError.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