Skip to main content
Migrate Already selling? Move your customers to Keylight without re-issuing a single key.
Keylight
Blog
electron javascript license-keys

How to Add License Keys to an Electron App

5 min read Nicolas Demanez — Founder

Electron apps ship outside the Mac App Store and the Microsoft Store as often as not, which means licensing is on you. The good news: because Electron runs Node.js in its main process, you can drop in a JavaScript licensing SDK without a backend of your own. This guide wires Keylight into an Electron app the right way — SDK in the main process, a thin IPC bridge to the renderer, and feature gating driven by the license state.

Where the Keylight SDK runs in an Electron app

The single most important decision is which process runs the SDK. An Electron app has two: the main process (full Node.js, no UI) and the renderer (the Chromium window your UI lives in). The licensing SDK belongs in the main process, for two reasons.

First, persistence. The license has to survive restarts, and the main process has filesystem access; the renderer does not, at least not when contextIsolation and nodeIntegration: false are set — which they should be. Second, exposure. The renderer is a web page. A user can open DevTools and read anything in it. Keeping your tenant configuration and the SDK instance in the main process means the renderer only ever sees the specific yes/no answers you choose to hand it.

So the architecture is: instantiate the SDK once in the main process, and expose a small, explicit surface to the renderer over Electron’s IPC. The renderer never touches the SDK directly — it asks the main process “activate this key” or “is this feature unlocked?” and renders the answer.

Installing and configuring the client in the main process

Install the SDK:

npm install @keylight-dev/js

Create the client once, in a module the main process imports. The SDK is configured with your Keylight tenant and product IDs, both of which you get from the dashboard:

// main/license.ts — runs in the Electron main process (Node).
import { app } from "electron";
import { Keylight, fetchKeyset, FetchTransport } from "@keylight-dev/js";

const TENANT = "your-tenant";
const PRODUCT = "your-product";

let keylight: Keylight;

export async function initLicensing() {
  // Pull the tenant's Ed25519 keyset once so leases verify offline.
  const ks = await fetchKeyset(new FetchTransport(), "https://api.keylight.dev", TENANT);

  keylight = new Keylight({
    tenantId: TENANT,
    productId: PRODUCT,
    appVersion: app.getVersion(),
    maxOfflineDays: 7, // offline grace window before a revalidation is required
    trustedKeys: ks?.keys ?? {}, // for local, offline lease verification
  });

  // Hydrate the cached lease from disk, then refresh online if a check is due.
  await keylight.load();
  await keylight.checkOnLaunch();
}

export function getKeylight() {
  return keylight;
}

Call initLicensing() from your app’s whenReady() handler, before you create the first window. Two calls do the work at startup: load() reads the previously stored lease off disk into memory, and checkOnLaunch() re-validates it online only when a refresh is actually due — it debounces, so calling it on every launch does not hammer the API. After this runs, the license state is resolved and the renderer can ask about it immediately.

trustedKeys is what makes the lease verifiable without a network call: the SDK checks the lease’s Ed25519 signature against the tenant’s public keys locally. For the full anatomy of that signed document, see what is inside a Keylight lease.

Bridging activation and state to the renderer over IPC

The renderer needs to do three things: submit a key the user typed, read the current license state to render the right UI, and check individual entitlements. Expose exactly those over ipcMain.handle:

// main/ipc.ts
import { ipcMain } from "electron";
import { getKeylight } from "./license";

ipcMain.handle("license:activate", async (_e, key: string) => {
  const res = await getKeylight().activate(key);
  return { activated: res.activated, error: res.error };
});

ipcMain.handle("license:state", () => getKeylight().state());

ipcMain.handle("license:hasEntitlement", (_e, feature: string) =>
  getKeylight().hasEntitlement(feature),
);

ipcMain.handle("license:deactivate", () => getKeylight().deactivate());

activate() is the one online call here — it hits Keylight, claims a device slot, and the returned lease is Ed25519-verified before anything is written to disk. state() and hasEntitlement() are synchronous reads from the cached lease, so they are cheap to call as often as the UI needs.

Then expose a safe wrapper to the renderer through the preload script — never nodeIntegration, always contextBridge:

// preload.ts
import { contextBridge, ipcRenderer } from "electron";

contextBridge.exposeInMainWorld("license", {
  activate: (key: string) => ipcRenderer.invoke("license:activate", key),
  state: () => ipcRenderer.invoke("license:state"),
  hasEntitlement: (f: string) => ipcRenderer.invoke("license:hasEntitlement", f),
  deactivate: () => ipcRenderer.invoke("license:deactivate"),
});

Now the renderer has a window.license object that proxies to the SDK in the main process, without ever importing it.

Gating features and handling the license states

In the renderer, activation is a form submit that calls through the bridge:

// renderer — license entry form
const res = await window.license.activate(userEnteredKey);
if (!res.activated) {
  showError(res.error ?? "That key could not be activated.");
}

To render the right UI, read the state. The SDK models the trial, the paid license, and every edge case as a single discriminated union, so one switch handles them all:

const state = await window.license.state();

switch (state.kind) {
  case "Licensed":
    // Full, paid access.
    break;
  case "Trial":
    showTrialBanner(`${state.daysLeft} days left in your trial`);
    break;
  case "Limited":
    // A previously valid lease, running on the offline grace window.
    break;
  case "FreeTier":
    showUpgradePrompt();
    break;
  case "Expired":
    showRenewPrompt();
    break;
  case "Invalid":
    showLicenseEntry();
    break;
}

For per-feature gating — unlocking a Pro export, say — ask about the specific entitlement rather than the broad state:

if (await window.license.hasEntitlement("pro")) {
  enableProExport();
}

hasEntitlement returns true only when the cached lease is valid, unexpired, and actually carries that entitlement string — which is how you sell multiple tiers from one binary. This mirrors how the Swift SDK gates a Mac app; the model is identical across Keylight’s SDKs.

Where the license is stored, and how to move it

In a Node environment — which is what Electron’s main process is — the SDK persists to a single JSON file at ~/.keylight.json by default. That works, but for a packaged app the tidier choice is Electron’s per-app data directory, so the file lives with your app’s other state and uninstalls cleanly. Inject an FsStore pointed wherever you want:

import { Keylight, FsStore } from "@keylight-dev/js";
import { app } from "electron";
import fs from "node:fs/promises";
import path from "node:path";

const licensePath = path.join(app.getPath("userData"), "license.json");

keylight = new Keylight({
  tenantId: TENANT,
  productId: PRODUCT,
  store: new FsStore(licensePath, fs),
  // ...rest of the config
});

The stored lease is plaintext on purpose — the security boundary is the Ed25519 signature, not at-rest secrecy. A user can read the file; they cannot edit it into a different entitlement without breaking the signature, and the SDK rejects any lease whose signature does not verify. For why that holds up offline, see how offline license validation works.


That is a complete Electron licensing integration: SDK in the main process, a narrow IPC surface, and feature gating driven by one license state. The same Keylight tenant backs your other apps too — if you also ship a Mac, Tauri, or Rust build, they all read from one control plane. If your Electron setup has a wrinkle this post does not cover, send us your feedback and we’ll extend it.

Frequently asked

Should the licensing SDK run in the Electron main process or the renderer?+

The main process. It has Node access for filesystem persistence and keeps your tenant configuration out of the renderer, where a determined user can open DevTools. Expose only the calls the UI needs over IPC.

Where does Keylight store the license in an Electron app?+

By default the Node SDK writes a single JSON file at ~/.keylight.json. You can point it at Electron's per-app data directory by injecting an FsStore with a custom path, which is the cleaner choice for a packaged app.

Does the license still work when the user is offline?+

Yes. After the first online activation, the lease is an Ed25519-signed document the SDK verifies locally on each launch. Set maxOfflineDays to bound how long a device can stay offline before it must revalidate.

Ready to ship?

Create your account and start licensing your apps in under a minute. Free forever tier included.

Start Free