Licensing Electron and Node Apps: Signed Config, Pinned Keys
Electron app licensing has one weakness nobody mentions in the tutorials: the app runs on a machine the customer fully controls. Whatever settings your build caches on disk, the customer can open in a text editor. If your trial length lives in that file, a 14-day trial becomes a 14,000-day trial with one keystroke. If a free-tier flag lives there, the flag flips.
That is the whole reason signed config exists in the Keylight JavaScript SDK, and it is the reason for this post. One package, @keylight-dev/js, covers two seats: the Electron main process that activates and gates features, and the Node service that checks a lease on the server side. Version 0.4.2 is the one to install. If you see 0.4.1 somewhere, it was tagged but never reached npm.
The IPC bridge between renderer and main is already covered in How to Add License Keys to an Electron App. I am not going to rewrite it. This post is about what sits on top of that bridge: settings the dashboard owns, a switch that makes those settings tamper-proof, and the refresh that makes a paid feature appear right after a customer upgrades.
One package, two seats: Electron main and Node
The SDK belongs in the Electron main process, full stop. It stores the license on disk, talks to api.keylight.dev, and holds the public keys that verify the offline lease. None of that has any business in the renderer. The renderer asks over IPC and gets back a state and a yes-or-no on entitlements.
Install:
npm install @keylight-dev/js
The package is dual ESM and CJS, needs Node 18 or newer, and pulls in exactly two runtime dependencies for the signature math. Nothing native to compile, so electron-builder and electron-forge both leave it alone.
Create the client once in main, load whatever was cached, and check on launch:
import { Keylight } from "@keylight-dev/js";
const kl = new Keylight({
tenantId: "your-tenant",
productId: "your-app",
trustedKeys: { k1: "<base64 ed25519 public key>" },
});
await kl.load();
await kl.checkOnLaunch();
const s = kl.state();
// s.kind is one of: Licensed | Limited | Trial | FreeTier | Expired | Invalid
if (s.kind === "Trial") console.log(`${s.daysLeft} days left`);
const pro = kl.hasEntitlement("pro"); // sync, read from the verified lease
Activation is one call, and the result tells you exactly what happened:
const res = await kl.activate(licenseKey);
if (!res.activated) showError(res.error);
// res.instanceId, res.lease, res.licenseExpiresAt are also there
checkOnLaunch() never throws. If the machine is offline it falls back to the signed lease on disk, which is what keeps the app working on a plane. hasEntitlement() is synchronous and reads from that same lease, so gating a menu item costs nothing.
The second seat is Node with no window at all. The honest primitive there is verifyLease. If a client hands your service the lease it holds, Node can verify it against your pinned keys without a network call:
import { verifyLease, isTrusted } from "@keylight-dev/js";
const now = Math.floor(Date.now() / 1000);
const r = verifyLease(lease, { k1: "<base64 ed25519 public key>" }, now);
if (!isTrusted(r) || r.expired) throw new Error("lease rejected");
What this package does not do is validate an arbitrary key from a server. Keylight.validate() is scoped to the device it runs on. If your Node service needs to ask “is this key good right now” for any key a customer types into a web form, that is the API path: two HTTPS calls, and you cache the answer until the revalidate_after timestamp the server returns, because the server owns expiry. I wrote that up in License Your App With No SDK: The Keylight API Path.
Server-owned trial length and free tier
Since 0.3.0 the trial length and the free-tier flag are the dashboard’s, not the build’s. The value you pass in the constructor is a seed for the first launch before any contact with the server. After that, the server value wins.
kl.effectiveTrialDurationDays(); // server value, else your seed, else 0
kl.effectiveFreeTierEnabled(); // server value, else your seed, else false
The settings ride along on validate() and on the keyless beacon, so there is no extra request on the launch path. fetchConfig() is the explicit refresh if you want it now. It returns nothing and never throws, so you can fire it from a settings screen without wrapping it.
await kl.fetchConfig();
Why this matters in practice: you shipped with a 14-day trial, conversion is fine but support tickets say people need longer to get their data in. Change 14 to 21 in the dashboard. Every install reads 21 on its next validate. No release, no auto-update prompt, no “please download the new version” email. The free trials and free tier page has the dashboard side of this.
Two details the SDK gets right that a hand-rolled version usually does not. A trial length of 0 is a real setting, not “unset”, so you can turn trials off from the dashboard. And startTrial() stamps the clock even at zero duration, so a later change to a real trial length does not hand out a fresh trial to someone who already used theirs.
Signed config with pinned keys: the attack it closes
Here is the problem with server-owned settings on their own. The SDK caches them on disk. On Node that cache is a plaintext JSON file, ~/.keylight.json by default. The security boundary of the whole system is the signature on the lease, not secrecy at rest, and the lease side is fine: a tampered lease fails signature verification. But the cached trial length and free-tier flag were, until 0.4.0, just numbers in a file.
So the attack is boring and effective. Open the file, change trial_duration_days from 14 to 9999, save. Or set free_tier_enabled to true on a product where you decided free means nothing. The app reads its own cache and believes it.
requireSignedConfig closes that door:
const kl = new Keylight({
tenantId: "your-tenant",
productId: "your-app",
trustedKeys: { k1: "<base64 ed25519 public key>" }, // pinned at build time
requireSignedConfig: true,
});
With the switch on, every settings body the SDK sees, whether it arrived on /config, on validate, or on the beacon, passes through one merge point that checks the signature against your pinned keys. A body that does not verify is dropped and never written to the cache. The app keeps its compiled-in seed. It does not crash, it does not lock anyone out, it just refuses to believe an unsigned number.
The keys you pin are the tenant keyset, served at GET /{tenant}/.well-known/keylight-keys. The package has a fetchKeyset helper for reading it during development. Pin the value into your build. Do not fetch it at runtime and trust whatever comes back, because keys fetched over the same channel as the config let anyone who can forge one forge the other.
Three honest caveats, because this switch ships off for a reason:
- The server signs a product’s settings only once that product has a trial length configured in the dashboard. Turn on
requireSignedConfigagainst a product with no trial length set and every legitimate response is rejected. The install stays pinned to its seed until you fix the dashboard. - Rotating to a new key ID does not break shipped builds. Old builds that only know the old key keep the last settings they verified. That is a freeze, not a failure, and it is the behavior you want when you cannot force an update.
- The at-rest cache stays plaintext. Signing makes editing pointless, it does not make reading impossible. Nothing in that file is secret anyway.
Every SDK in the Keylight family verifies the same frozen payload against the same golden vector captured from production, so a settings body that verifies in JavaScript verifies identically in Swift, Rust, C#, and C++. One sentence on that is enough.
Post-upgrade refresh: the paid feature appears without a restart
A customer on your Standard tier opens the hosted portal, buys Pro, and comes back to the app. The old experience is “quit and reopen” or, worse, “paste this new key”. The new one is that the Pro menu item is just there.
refreshAfterUpgrade() is a bounded poll. It snapshots the current entitlement set and state, validates every two seconds, and resolves true the moment either changes. It resolves false on timeout, on abort, or immediately with zero network calls if no license is stored.
// main process
import { ipcMain } from "electron";
ipcMain.handle("keylight:refresh-after-upgrade", () => kl.refreshAfterUpgrade());
// renderer, right after the portal window closes
const changed = await window.keylight.refreshAfterUpgrade();
if (changed) render();
Defaults are 30 seconds total and a 2-second interval, both in milliseconds. An AbortSignal as the third argument lets you cancel if the user closes the window. Since 0.4.1 the internal timer is unref’d, so a pending poll never keeps an Electron process alive on quit, and 0.4.2 fixed the one extra poll it used to make past the timeout.
Same key, higher tier. The customer keeps one license for life, and the entitlement list on the lease is what changed.
What to do before you ship
Pick the order that avoids a support email:
- Set the trial length in the dashboard first, even if you also pass a seed. This is what makes the server sign your settings.
- Pin the keyset into the build and turn on
requireSignedConfig. Do it before launch, not after the first leaked trial trick shows up on a forum. - Wire
refreshAfterUpgrade()to whatever closes your portal window. - Leave the renderer dumb. It asks, main answers.
If you are still choosing a shell, Tauri vs Electron for Licensed Desktop Apps is where I compare where verification lives in each. The install and option reference for this package is in the JavaScript SDK docs, and plans start free on the pricing page.
If you hit something this post does not cover, send your feedback and I will extend it.
Nicolas Demanez, Founder
Frequently asked
Does the Keylight JavaScript SDK run in the Electron renderer or the main process?+
The main process. The SDK stores the license on disk and talks to the Keylight API, both of which belong on the Node side. The renderer asks the main process over IPC and only ever sees the answer, never the key material.
What does requireSignedConfig protect against?+
Someone editing the cached settings file on disk to stretch a trial or flip the free-tier flag. With the switch on, any settings body that does not verify against the public keys you pinned is dropped and never cached, so the app keeps the values it shipped with.
Can I change the trial length without shipping a new Electron build?+
Yes. Trial length and the free-tier flag are owned by the dashboard. The SDK reads them when it validates and on the keyless beacon, and fetchConfig() refreshes them on demand. Change the number in the dashboard and installs pick it up.
Can a Node backend validate an arbitrary license key with this package?+
The package gives Node the verifyLease primitive for checking a signed lease a client presents. For a live check of any key from a server, use the two-call HTTPS API and cache the result until the revalidate_after timestamp it returns.
Ready to ship?
Create your account and start licensing your apps in under a minute. Free forever tier included.
Start Free