Documentation menu▾

Electron

Activate license keys in an Electron app, check a signed token offline in the main process and handle revoked licenses. Complete code to copy.

This guide adds license keys to an Electron app: 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 code below is complete and runs as is. For what each step does on the server, see Activating licenses in your app.

Where the check runs

Put the license code in the main process. The renderer is a web page that anyone can open with the developer tools, while the main process decides which window to show and can keep the saved key out of reach of page scripts. The renderer only asks the main process to activate a key and shows the result.

The license module

The module needs one package to read the operating system's machine ID, which keeps the device the same across restarts and reinstalls:

bash
npm install node-machine-id

Fill in your server address and the public key from /api/v1/license/pubkey. The file uses ES modules, which the main process supports since Electron 28.

license.js
import { app } from "electron";
import { createPublicKey, verify } from "node:crypto";
import { readFileSync, rmSync, writeFileSync } from "node:fs";
import { hostname } from "node:os";
import { join } from "node:path";
import machineId from "node-machine-id";

const SERVER = "https://licenses.example.com";
const PUBLIC_KEY_HEX = "3b6a27bc..."; // from /api/v1/license/pubkey
const OFFLINE_DAYS = 7; // how long to keep working when the server cannot be reached

const publicKey = createPublicKey({
  key: Buffer.concat([
    Buffer.from("302a300506032b6570032100", "hex"),
    Buffer.from(PUBLIC_KEY_HEX, "hex"),
  ]),
  format: "der",
  type: "spki",
});

// The OS machine ID, hashed with SHA256, so it is stable but not readable.
const identifier = machineId.machineIdSync();
const file = join(app.getPath("userData"), "license.json");

function load() {
  try {
    return JSON.parse(readFileSync(file, "utf8"));
  } catch {
    return null;
  }
}

function readToken(token) {
  const [payload, signature] = token.split(".");
  if (!verify(null, Buffer.from(payload), publicKey, Buffer.from(signature, "base64url"))) {
    return null;
  }
  const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
  return claims.did === identifier ? claims : null;
}

async function call(action, body) {
  const res = await fetch(`${SERVER}/api/v1/license/${action}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ...body, identifier }),
  });
  const json = await res.json();
  if (!res.ok) {
    throw Object.assign(new Error(json.error.message), { status: res.status, code: json.error.code });
  }
  return json.data;
}

export async function activate(licenseKey) {
  const data = await call("activate", { license_key: licenseKey, label: hostname() });
  writeFileSync(file, JSON.stringify({ licenseKey, token: data.token }));
  return readToken(data.token);
}

// Returns the license claims, or null when the app is not licensed.
export async function check() {
  const saved = load();
  if (!saved) return null;

  const claims = readToken(saved.token);
  const now = Date.now() / 1000;
  if (claims && claims.exp > now) return claims;

  try {
    const data = await call("verify", { license_key: saved.licenseKey });
    writeFileSync(file, JSON.stringify({ ...saved, token: data.token }));
    return readToken(data.token);
  } catch (err) {
    if (err.status === 404) {
      rmSync(file);
      return null;
    }
    // No connection: trust the last good token for a few more days.
    return claims && claims.exp + OFFLINE_DAYS * 86400 > now ? claims : null;
  }
}

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.

Wiring it up

On startup, check the license and open the right window. Expose activation to the renderer through ipcMain.handle:

main.js
import { app, BrowserWindow, ipcMain } from "electron";
import { fileURLToPath } from "node:url";
import { activate, check } from "./license.js";

function open(page) {
  const win = new BrowserWindow({
    webPreferences: { preload: fileURLToPath(new URL("preload.cjs", import.meta.url)) },
  });
  win.loadFile(page);
}

app.whenReady().then(async () => {
  // Errors lose their details on the way to the renderer, so send the code as data.
  ipcMain.handle("license:activate", (_event, key) =>
    activate(key).catch((err) => ({ error: err.code ?? "OFFLINE" })),
  );
  const license = await check();
  open(license ? "index.html" : "activate.html");
});
preload.cjs
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("license", {
  activate: (key) => ipcRenderer.invoke("license:activate", key),
});

The activation page calls window.license.activate(key). It resolves with the license, or with an error holding the Keygate error code, such as ACTIVATION_LIMIT when every device slot is taken, or OFFLINE when the server cannot be reached. The error table lists what to tell the customer for each one.

Features come with the license. claims.ftr holds the plan's features, so the app can turn parts of itself on and off without another request. 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.
  • Add a way to deactivate from the app, so customers can move to a new computer without writing to you.
  • Ship updates through Keygate too, so only licensed customers receive them. See Shipping updates.

Last updated October 4, 2026