License Server v1.25.0 · released 28 Oct 2026release notes →

Licensing an Electron or Tauri desktop app

Last updated October 27, 2026

This guide adds licensing to a desktop application written in JavaScript — Electron, Tauri, or anything else that ships a JS runtime to a customer's machine — using @casazium/license-sdk.

Everything here is also doable with plain HTTP calls; the SDK is a convenience layer over the API, not a separate system.

See it running. The License demo is a small, open-source Electron app that does everything in this guide against a live demo server, with every call it makes listed beside the result. Get a free test key from inside the app, then try going offline, a revoked key, and moving machines.

What you are building

Four moments matter, and most licensing bugs come from confusing them:

  1. Activation — once, when a customer first enters their key on this machine. It consumes a seat and returns a token you must keep.
  2. The launch check — every time the app starts, to confirm the key is still valid.
  3. Being offline — the app must keep working when the network does not. This is the part people get wrong.
  4. Moving machines — freeing the seat so the same key works on a new laptop.

Install

npm install @casazium/license-sdk
import { CasaziumLicenseClient } from '@casazium/license-sdk';

const client = new CasaziumLicenseClient({
  baseUrl: process.env.LICENSE_SERVER_URL ?? 'https://license.example.com/v1',
  // Give up on a server that doesn't answer, rather than hold up launch.
  retries: 1,
  fetch: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(5000) }),
});

// Your product's UUID: in the response to your first issue-license call,
// and on the license's page in the console. It never changes.
const MY_PRODUCT_UUID = 'your-product-uuid';

The launch check below uses validate(), added in SDK 0.4.0.

Set a timeout. By default the SDK retries a failed request three times, and fetch has no timeout you can rely on, so a network that drops packets instead of refusing them can hold up launch for minutes. One retry and five seconds per attempt keep the offline fallback in step 3 quick.

Your API address goes in LICENSE_SERVER_URL, including /v1. On a hosted account it's https://license-cloud-api.casazium.com/v1, not the console's license-cloud.casazium.com, which answers API calls with a 404 naming the right address. Self-hosted, it's your own server's address plus /v1 (http://localhost:3001/v1 for the self-hosted bundle on the machine running it). license.example.com above is only a stand-in.

Read the base URL from configuration, not a hardcoded string. If you ever move between the hosted tier and your own server — in either direction — a hardcoded URL means shipping an update to every customer.

MY_PRODUCT_UUID is what makes a license yours. The SDK checks every license and update against it and refuses one for a different product, so pass it wherever a method asks for it.

1. Activate on first run

Activation binds the key to one machine. instance_id is the label you choose for that machine, and the SDK ships a helper that derives a stable one:

import { getFingerprint } from '@casazium/license-sdk/fingerprint';

const key = await promptCustomerForKey();
const instanceId = getFingerprint();

// Check the key first, so a revoked key or one for another product
// doesn't take a seat. Throws if the server refuses it.
await client.verifyKey(key, MY_PRODUCT_UUID);

const { token } = await client.activate(key, instanceId);
if (!token) {
  // Activated on this machine before, and the token was lost.
  return showFreeSeatInPortalScreen();
}
await saveLicense({ key, token, instanceId });

Keep the token. It is returned only once, by the call that creates the activation, and it is the credential required to free that seat later. Lose it and your customer cannot move machines without asking you to intervene.

That is what the !token branch handles. If this machine was activated before (a reinstall, cleared storage), the server answers { activated: true, alreadyActivated: true } with no token. Don't save a license without one: ask the customer to free this machine's seat in the license portal (or to ask you, if you don't offer the portal), then activate again.

getFingerprint() is Node-only

It uses node-machine-id, which needs a Node runtime. In Electron that means the main process. In Tauri, whose front-end is a browser context, generate a UUID once, persist it, and pass that as instance_id — it is a caller-supplied label, and the server does not require it to be a real machine identifier.

2. Check on every launch

Two questions, two calls: is the license still good, and is this machine still activated on it?

async function launchCheck() {
  const { key, instanceId } = await loadLicense();
  await client.verifyKey(key, MY_PRODUCT_UUID);            // throws if revoked, expired or unknown
  const { valid } = await client.validate(key, instanceId); // false if this seat was freed elsewhere
  return valid;
}

verifyKey checks only the license. On its own it keeps passing after the customer freed this machine's seat from another computer or the license portal; validate() (SDK 0.4.0+) is what notices. When it returns false, ask the customer to activate this machine again.

validate() is also this machine's check-in. If you give the license a lease (License Server 1.14.0+), a machine that hasn't checked in for that long can lose its seat to another machine, and validate() then returns false the same way. See Seats that free themselves.

Both are public endpoints — no admin credential — and sit within the verification rate-limit tier. Once per launch is well within it. Do not call them in a loop, on a timer, or on every window focus.

3. Handle being offline — properly

A launch check that fails closed will lock a paying customer out of your application because their train went into a tunnel. A network error is not an invalid license. The SDK tells them apart for you: a refusal from the server throws an error with its HTTP status on err.status (SDK 0.4.0+), while no answer at all throws with no status. One refusal has no status: verifyKey throws ... (product_uuid mismatch) itself when the server answered with a license for another product.

function isRefusal(err) {
  if (err.status === undefined) return /product_uuid mismatch/.test(err.message);
  // A 404 ending ": Not Found" is a route the server doesn't have (a server
  // older than the SDK needs, or a base URL missing /v1), not a refused key.
  if (err.status === 404 && /: Not Found$/.test(err.message)) return false;
  return err.status >= 400 && err.status < 500 && err.status !== 429;
}

try {
  if (!(await launchCheck())) {
    await forgetOfflineAccess();            // this seat was freed elsewhere
    return showActivateAgainScreen();
  }
  await recordSuccessfulCheck();
  startApp();
} catch (err) {
  if (isRefusal(err)) {
    await forgetOfflineAccess();            // revoked, expired, unknown, other product
    return showLicenseScreen(err.message);
  }
  // No answer: offline, timed out, rate limited, server error, or a
  // route the server doesn't have.
  if (await withinOfflineLimit({ days: 14 })) {
    startApp();
  } else {
    showOfflineTooLongScreen();
  }
}

recordSuccessfulCheck() saves the time of this good answer, and withinOfflineLimit() asks whether the last one was at most 14 days ago. forgetOfflineAccess() deletes that time, and the offline file below if you keep one. Without it, a customer whose license was just revoked could block your server and keep working until the limit ran out.

Keep that time, and the offline file below, with the key and token in the system keychain (see Where the code goes): in a plain file, the customer could edit the time and lift the limit.

A 429 means "slow down", not "no": treat it like being offline.

Pick a limit long enough to cover a normal holiday. Fourteen days is a reasonable default; it is your decision, not the server's.

For a stronger offline story, keep a signed license file and verify it locally with no network at all. Fetch it after activation and again after each good launch check, so its expiry and terms follow the server's:

// After a good online check, before startApp(). A failed export just
// keeps the file you have: it isn't a reason to treat the check as failed.
await client.exportOfflineLicense(key).then(saveOfflineFile).catch(() => {});

// When there's no answer, instead of withinOfflineLimit() alone:
const offlineClient = new CasaziumLicenseClient({ publicKey });
const ok =
  (await withinOfflineLimit({ days: 14 })) &&
  (await offlineClient.verifySignedFile(await loadOfflineFile(), MY_PRODUCT_UUID));

verifySignedFile is true only for a genuine license for your product that hasn't expired. It doesn't check which key the file is for, so also check that file.license.key is the customer's own key. The signature is checked against your server's public key, so a file can't be forged or edited — see License Verification for the issuing side. From License Server 1.20.0 the file signs exported_at, so you can count time offline from it rather than a time your app saved: see What the file signs.

What the file can't show is anything that happened after it was exported. A file exported before you revoked the license still verifies, and a perpetual license's file never expires. That is why the app keeps its own limit on time offline, and deletes the file as soon as the server refuses the license or validate() returns false.

4. Let customers move machines

const { key, token } = await loadLicense();
try {
  await client.deactivate(key, token);
} catch (err) {
  if (!isRefusal(err)) throw err; // no answer: keep it, try again later
  // Refused: the license was deleted. Nothing to give back.
}
await clearLocalLicense();

The server answers { deactivated: false } when the token no longer matches a seat, for example because it was already freed from the portal; and it refuses outright when the license itself was deleted. Either way this machine is done with the license, so clear it. Only when the server can't be reached does the call throw before anything is cleared, so the customer can try again once they're back online. Deactivating still works for a revoked license, and for a sandbox license its product refuses (License Server 1.20.0+).

Put this behind a visible "deactivate this computer" control. The alternative is an email to you every time somebody buys a laptop.

Since v1.2.0 there is also an end-user portal your customers can use themselves, with no account and no involvement from you — worth enabling if you would rather not build this screen.

5. Optional: check for updates

If you distribute builds through License Server, the same key can gate update checks:

const { update_available, release } = await client.checkUpdate({
  key,
  productId: 'your-product-id',
  expectedProductUuid: MY_PRODUCT_UUID,
  platform: 'darwin-arm64',
  currentVersion: app.getVersion(),
});

if (update_available) offerUpdate(release);

It resolves to { update_available: false, release: null } when there is nothing newer rather than throwing, so it is safe on every launch. See Software Distribution.

Where the code goes

Electron. Put all of it in the main process and expose only narrow IPC handlers to the renderer — activate(key), getStatus(), deactivate(). The renderer is a browser window; treat it as untrusted. Store the key and token with safeStorage, which uses the OS keychain, not in localStorage and not in plain JSON beside your app.

Tauri. Either call the SDK from the front-end — it ships a browser-safe build that is selected automatically by Vite and other bundlers — or do the licensing in Rust and expose commands to the front-end. Prefer Rust if you want the key out of the web context. Use the OS keychain rather than localStorage.

What not to do

  • Never ship an admin API key. Anything that issues, lists or revokes licenses belongs on a server you control. The SDK's browser build omits listLicenses() and revoke() for exactly this reason.
  • Do not treat a network failure as an invalid license (step 3).
  • Do not verify only in the renderer, where a user can open dev tools and change the answer. Enforce in the main process or in Rust.
  • Do not hardcode the base URL — configuration, so moving hosts is not a release.
  • Do not check on a timer. Once per launch, plus after the customer enters a key.

A note on what licensing can and cannot do

A determined person with a debugger can patch out any client-side check, in any language. Licensing is not copy protection — it exists so that honest customers can pay you, manage their own seats, and be told when something expires. Build it to be unobtrusive when it works and forgiving when the network fails, and it will cost you far less support than a system that treats every customer as a suspect.

Next steps

More in Guides, or the full documentation.