Electron Licensing
Add license activation, validation, and revocation to your Electron app in 15 minutes. Supports node-locked, floating, and offline licensing.
Problem
You built an Electron app and need to prevent unauthorized copying. You need license keys that bind to a specific machine, a user-friendly activation flow, and optional offline support for air-gapped deployments. Building this from scratch means dealing with hardware fingerprinting, secure local storage, activation counting, expiry checks, and a management dashboard.
Keymint provides the licensing backend, SDK, and dashboard. You add one dependency and a few lines of code.
Architecture Diagram
Create Product
Create a product from the Keymint Dashboard. This groups your Electron app's licenses together.
- Sign in to app.keymint.dev
- Navigate to Products
- Name it after your Electron app (e.g. "MyElectronApp")
- Copy the product ID
Create License Policy
When generating a license key from the dashboard, save your settings as a Template (in More options, open Save as a template). This lets you re-use the same configuration for future keys.
| Setting | Recommended Value | Purpose |
|---|---|---|
| licenseType | node-locked | Binds license to one machine |
| maxActivations | 1 | Single device per license key |
| expiry | None (perpetual) or 365 days | Annual subscription model |
| format | A8E2K-9F1BC-3D4GH-7J2KM | User-friendly segmented key format |
Saved Templates store reusable defaults. When you generate keys, they can prefill the template's settings — no need to re-enter limits or format every time.
Issue License
Generate license keys from the Dashboard or programmatically:
- Navigate to Licenses
- Click Generate license key
- Select your product and, optionally, a saved template
- Set quantity, optional customer assignment
- Download or copy the generated keys
Save the raw license key when it's returned — it's encrypted at rest, and the API can't look a key up without already knowing it. You can still view or export keys in the dashboard.
Validate License
Install the Keymint SDK and electron-store for local license state. The SDK's KeyMint.getOrCreateInstallationId() provides a stable host ID, so no separate fingerprinting package is needed:
npm install keymint electron-storeThe SDK throws on every non-2xx response, with the HTTP status, the numeric code and the stable errorCode (for example license_invalid_or_forbidden) on the error. Treat 401, 402, 403 and 404 as "this license was rejected"; anything else (no network, 429, 5xx) is transient and must not lock the user out.
Create a license module in your Electron app:
import { KeyMint } from 'keymint';
import Store from 'electron-store';
// process.env is only populated in development unless you load it yourself
// (for example with dotenv) or inject the values at build time.
const client = new KeyMint(process.env.KEYMINT_CLIENT_API_KEY!);
const PRODUCT_ID = process.env.KEYMINT_PRODUCT_ID!;
const store = new Store<{ licenseKey?: string; activated?: boolean }>();
export function getHostId(): string {
return KeyMint.getOrCreateInstallationId();
}
// 401 = invalid, expired, blocked or not allowed on this device;
// 402 = vendor subscription lapsed; 403 = activation limit reached;
// 404 = the product was deactivated or removed.
export function isLicenseRejected(error: unknown): boolean {
const status = (error as { status?: number } | null)?.status;
return status === 401 || status === 402 || status === 403 || status === 404;
}
export function getStoredLicenseKey(): string | undefined {
return store.get('licenseKey');
}
export function isActivated(): boolean {
return store.get('activated') === true;
}
export async function activateLicense(licenseKey: string): Promise<{
success: boolean;
message: string;
}> {
try {
const result = await client.activateKey({
productId: PRODUCT_ID,
licenseKey,
hostId: getHostId(),
});
store.set('licenseKey', licenseKey);
store.set('activated', true);
return { success: true, message: result.message };
} catch (error: any) {
return {
success: false,
message: isLicenseRejected(error)
? error.message
: 'Could not reach the licensing server. Try again later.',
};
}
}
export async function deactivateLicense(): Promise<boolean> {
const licenseKey = getStoredLicenseKey();
if (!licenseKey) return false;
try {
await client.deactivateKey({
productId: PRODUCT_ID,
licenseKey,
hostId: getHostId(),
});
store.delete('licenseKey');
store.delete('activated');
return true;
} catch {
return false;
}
}In your Electron main process, gate app launch behind license validation. Re-activating on the same device doesn't use another seat, so it doubles as a validity check:
import { app, BrowserWindow } from 'electron';
import { activateLicense, getStoredLicenseKey } from './license';
app.whenReady().then(async () => {
const storedKey = getStoredLicenseKey();
if (storedKey) {
const result = await activateLicense(storedKey);
if (result.success) {
createWindow();
return;
}
}
// Show activation window — user enters key, calls activateLicense()
createActivationWindow();
});
function createWindow() {
const win = new BrowserWindow({ width: 1200, height: 800 });
win.loadFile('index.html');
}
function createActivationWindow() {
const win = new BrowserWindow({ width: 480, height: 320 });
win.loadFile('activation.html');
}Don't deactivate on quit. before-quit runs every time the app closes, so it would free the seat and forget the key on every exit. Offer deactivation as an explicit action instead, for example "Move license to another computer" in settings.
To allow offline use, open the app when isActivated() is true even if the launch check couldn't reach the server, and re-check later.
Handle Activations
Build a simple activation UI using Electron's IPC:
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('licenseAPI', {
activate: (key: string) => ipcRenderer.invoke('license:activate', key),
getStatus: () => ipcRenderer.invoke('license:status'),
});import { ipcMain } from 'electron';
import { activateLicense, isActivated, getStoredLicenseKey } from './license';
ipcMain.handle('license:activate', async (_event, licenseKey: string) => {
return activateLicense(licenseKey);
});
ipcMain.handle('license:status', () => {
return {
activated: isActivated(),
licenseKey: getStoredLicenseKey() ? '••••••••' : null,
};
});<!DOCTYPE html>
<html>
<body>
<h2>Activate License</h2>
<input id="key" type="text" placeholder="A8E2K-9F1BC-3D4GH-7J2KM" />
<button id="activate">Activate</button>
<p id="status"></p>
<script>
const status = document.getElementById('status');
document.getElementById('activate').addEventListener('click', async () => {
status.textContent = 'Activating...';
const key = document.getElementById('key').value.trim();
const result = await window.licenseAPI.activate(key);
status.textContent = result.success
? 'License valid!'
: `Failed: ${result.message}`;
});
</script>
</body>
</html>Handle Revocations
You can block a license from the Dashboard or programmatically:
// From your backend (admin API key required)
await admin.blockKey({
productId: "YOUR_PRODUCT_ID",
licenseKey: "A8E2K-9F1BC-3D4GH-7J2KM",
});When a license is blocked, the next activateKey call throws an error with status: 401 and errorCode: "license_invalid_or_forbidden". Expired, unknown and blocked keys deliberately return the same response. Branch on status or errorCode, not on the message text, which may be reworded.
Re-check periodically, and only clear the license when the server rejects it:
export async function checkLicenseHealth(): Promise<'valid' | 'rejected' | 'unknown'> {
const licenseKey = getStoredLicenseKey();
if (!licenseKey) return 'rejected';
try {
await client.activateKey({
productId: PRODUCT_ID,
licenseKey,
hostId: getHostId(),
});
return 'valid';
} catch (error) {
return isLicenseRejected(error) ? 'rejected' : 'unknown';
}
}
// Run periodically (e.g. once per day)
setInterval(async () => {
if ((await checkLicenseHealth()) === 'rejected') {
store.delete('activated');
// Notify renderer to show re-activation dialog
}
}, 24 * 60 * 60 * 1000);Production Considerations
API Key Security
- Bundle the
client-scoped API key in your Electron app. This is the only scope safe for distribution — it can only activate and deactivate devices and check out, heartbeat and check in floating seats. - Never bundle
adminorread-onlyAPI keys. These can read all customer data and manage all licenses. - Rotate API keys from the Dashboard every 90 days.
Host ID Stability
Choose a host ID strategy that survives OS reinstalls and hardware changes:
| Method | Stability | Example |
|---|---|---|
KeyMint.getOrCreateInstallationId() (default) | Random installation ID mixed with a hardware anchor, hashed and persisted to ~/.keymint/installation-id. Stable across restarts and app updates; a new ID (and a new seat) after an OS reinstall or a wiped home directory | KeyMint.getOrCreateInstallationId() |
node-machine-id | Survives OS reinstall on most systems | machineIdSync() |
| MAC address | Changes with network adapters | Not recommended |
If
hostIdchanges unexpectedly, the activation counts as a new device and consumes another seat. Use a stable, predictable host ID.
Offline Support
If your Electron app needs to work offline, use Keymint's offline licensing:
// From your backend (admin key). Signing also activates hostId on the
// license, so it uses a seat like an online activation.
const offlineLicense = await admin.signKey({
productId: "YOUR_PRODUCT_ID",
licenseKey: "A8E2K-9F1BC-3D4GH-7J2KM",
hostId: "HOST_ID_FROM_THE_MACHINE", // getHostId() on the air-gapped machine
ttl: 60 * 60 * 24 * 30, // 30 days
});
// offlineLicense.file is the .lic file contents (JSON with the signed JWT)
// Save this file and distribute it to the air-gapped machineThen verify offline in Electron using jose:
import { jwtVerify, decodeProtectedHeader, importSPKI } from 'jose';
import * as fs from 'fs';
const TRUSTED_KEYS = new Map([
['AbC123XyZ', '-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAr7Z8s2Z...\n-----END PUBLIC KEY-----']
]);
export async function verifyOfflineLicense(filePath: string, productId: string, machineCode: string) {
const fileContent = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const { signedKey } = fileContent;
const { kid } = decodeProtectedHeader(signedKey);
if (!kid) throw new Error('Invalid token: missing kid');
const pem = TRUSTED_KEYS.get(kid);
if (!pem) throw new Error('Untrusted key ID');
const publicKey = await importSPKI(pem, 'EdDSA');
const { payload } = await jwtVerify(signedKey, publicKey, {
algorithms: ['EdDSA'],
});
// One signing key covers every product in your workspace, so always check
// the product as well as the machine.
if (payload.type !== 'offline') throw new Error('Not an offline license');
if (payload.productId !== productId) throw new Error('Product mismatch');
if (payload.machineCode !== machineCode) throw new Error('Hardware mismatch');
return payload;
}Offline licensing is available on all plans. See Offline Verification for the full workflow.
Code Signing
Always code-sign your Electron app. Unsigned apps on macOS trigger Gatekeeper warnings that discourage users from entering license keys.
Auto-Updates
If you use electron-updater, store the license key and activation state outside the app bundle (e.g. in app.getPath('userData')) so updates don't wipe the license.
Complete Source Code
Full Electron license module
// src/main/license.ts
import { KeyMint } from 'keymint';
import Store from 'electron-store';
interface LicenseStore {
licenseKey?: string;
activated?: boolean;
lastCheck?: number;
}
const client = new KeyMint(process.env.KEYMINT_CLIENT_API_KEY!);
const PRODUCT_ID = process.env.KEYMINT_PRODUCT_ID!;
const store = new Store<LicenseStore>();
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
export type LicenseHealth = 'valid' | 'rejected' | 'unknown';
export function getHostId(): string {
return KeyMint.getOrCreateInstallationId();
}
// The SDK throws on every non-2xx response. 401/402/403/404 mean the license
// was rejected; no response, 429 and 5xx are transient.
export function isLicenseRejected(error: unknown): boolean {
const status = (error as { status?: number } | null)?.status;
return status === 401 || status === 402 || status === 403 || status === 404;
}
export function getStoredLicenseKey(): string | undefined {
return store.get('licenseKey');
}
export function isActivated(): boolean {
return store.get('activated') === true;
}
export async function activateLicense(licenseKey: string): Promise<{
success: boolean;
message: string;
}> {
try {
const result = await client.activateKey({
productId: PRODUCT_ID,
licenseKey,
hostId: getHostId(),
});
store.set('licenseKey', licenseKey);
store.set('activated', true);
store.set('lastCheck', Date.now());
return { success: true, message: result.message };
} catch (error: any) {
return {
success: false,
message: isLicenseRejected(error)
? error.message
: 'Could not reach the licensing server. Try again later.',
};
}
}
// Call from an explicit "Deactivate" action, never from before-quit.
export async function deactivateLicense(): Promise<boolean> {
const licenseKey = getStoredLicenseKey();
if (!licenseKey) return false;
try {
await client.deactivateKey({
productId: PRODUCT_ID,
licenseKey,
hostId: getHostId(),
});
store.delete('licenseKey');
store.delete('activated');
store.delete('lastCheck');
return true;
} catch {
return false;
}
}
export async function checkLicenseHealth(): Promise<LicenseHealth> {
const licenseKey = getStoredLicenseKey();
if (!licenseKey) return 'rejected';
// Skip if checked recently
const lastCheck = store.get('lastCheck') ?? 0;
if (Date.now() - lastCheck < CHECK_INTERVAL) {
return isActivated() ? 'valid' : 'rejected';
}
try {
await client.activateKey({
productId: PRODUCT_ID,
licenseKey,
hostId: getHostId(),
});
store.set('activated', true);
store.set('lastCheck', Date.now());
return 'valid';
} catch (error) {
if (!isLicenseRejected(error)) return 'unknown'; // offline: keep cached state
store.set('activated', false);
return 'rejected';
}
}
export function startHealthCheck(onRejected: () => void, interval = CHECK_INTERVAL) {
setInterval(async () => {
if ((await checkLicenseHealth()) === 'rejected') onRejected();
}, interval);
}