Electron powers VS Code, Slack, Discord, Figma, and hundreds of commercial desktop apps. But licensing desktop software built with web technology creates a unique set of challenges: there is no app store enforcing DRM, JavaScript source is straightforward to decompile, and users expect full offline functionality. This guide covers everything you need to ship production-grade licensing in an Electron app — from first install to advanced floating seat management.
The Licensing Challenge for Desktop Apps
SaaS applications validate users server-side on every request. Desktop apps cannot rely on that model. Once a user downloads your .exe, .dmg, or .AppImage, the binary lives on their machine — and your server has limited control over what happens next.
Electron compounds this problem in three ways:
- Transparent packaging — Electron bundles your app inside an
app.asararchive, which is a flat file format that anyone can extract withnpx @electron/asar extract. Your business logic, validation routines, and API keys are exposed unless you take explicit countermeasures. - Chromium DevTools — Unless disabled in production builds, users can open the inspector and modify runtime state, including variables that control license checks.
- Node.js runtime — Electron’s main process has full filesystem and network access. Patching a single
if (valid)check in your source can bypass licensing entirely.
A robust licensing strategy for Electron needs to account for all three attack surfaces while still delivering a frictionless experience for legitimate users.
What Modern Licensing Needs
Before writing code, establish the requirements your licensing layer must satisfy:
| Requirement | Why It Matters |
|---|---|
| Online validation | Confirms the license key is active, not revoked, and within its activation limit — the baseline for any licensing system. |
| Offline validation | Desktop users work on planes, in restricted networks, and behind corporate proxies. You need cryptographic proof that survives without connectivity. |
| Device fingerprinting | Binding a license to specific hardware prevents unauthorized redistribution of keys across unlimited machines. |
| Grace periods | Locking users out immediately when the network is unavailable is hostile UX. Caching the last validation result with a configurable TTL is the standard approach. |
| Floating licenses | Enterprise customers need concurrent seat pools — 50 engineers sharing 10 seats across time zones. |
| Anti-tampering | Bytecode compilation, ASAR integrity checks, and code signing raise the cost of circumvention. |
| VM detection | Prevents cloning a licensed machine image to spin up unlimited instances in cloud environments. |
Traffic Orchestrator addresses each of these through a single API and the @traffic-orchestrator/client SDK. The sections below walk through implementation step by step.
Step 1 — Install the SDK
Install the Traffic Orchestrator Node.js client in your Electron project:
npm install @traffic-orchestrator/client
@traffic-orchestrator/client. This is the officially published SDK on npm with TypeScript definitions included.
You will also need node-machine-id for hardware fingerprinting (covered in Step 5) and electron-store for encrypted local persistence:
npm install node-machine-id electron-store
Step 2 — Initialize in the Main Process
Create a dedicated license module in your main process:
// src/main/license.ts
import { TrafficOrchestrator } from '@traffic-orchestrator/client'
import Store from 'electron-store'
import { machineIdSync } from 'node-machine-id'
// Encrypted local store for license state
const store = new Store({
encryptionKey: process.env.STORE_ENCRYPTION_KEY,
name: 'license-state',
})
// Initialize the TO client
// API key should come from environment variables, never hardcoded
const to = new TrafficOrchestrator({
apiKey: process.env.TO_API_KEY!,
})
// Machine fingerprint — consistent across reboots
const machineId = machineIdSync({ original: true })
export { to, store, machineId }
Key decisions in this setup:
electron-storeusesencryptionKeyto AES-encrypt the JSON file on disk, preventing trivial tampering with cached license data.machineIdSync({ original: true })returns the raw machine GUID rather than a hashed version, giving you full control over how you hash it (covered in Step 5).- The API key is loaded from an environment variable. For distribution, embed it in your build pipeline — never commit it to source control.
Step 3 — Validate on App Startup
Wire license validation into your Electron app’s startup sequence. The validation call happens before the main window loads:
// src/main/index.ts
import { app, BrowserWindow } from 'electron'
import { to, store, machineId } from './license'
const createMainWindow = () => {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
},
})
win.loadFile('index.html')
return win
}
const createActivationWindow = () => {
const win = new BrowserWindow({
width: 480,
height: 360,
resizable: false,
webPreferences: {
contextIsolation: true,
preload: __dirname + '/preload-activation.js',
},
})
win.loadFile('activation.html')
return win
}
app.whenReady().then(async () => {
const licenseKey = store.get('license_key') as string | undefined
if (!licenseKey) {
createActivationWindow()
return
}
try {
const result = await to.validate({
licenseKey,
machineId,
})
if (result.valid) {
// Cache successful validation for offline use
store.set('last_validation', {
valid: true,
timestamp: Date.now(),
expiresAt: result.license?.expiresAt,
features: result.license?.features ?? [],
})
createMainWindow()
} else {
// License invalid or revoked
store.delete('last_validation')
createActivationWindow()
}
} catch (error) {
// Network error — fall back to cached validation
const cached = store.get('last_validation') as {
valid: boolean
timestamp: number
} | undefined
if (cached?.valid && isWithinGracePeriod(cached.timestamp)) {
createMainWindow()
} else {
createActivationWindow()
}
}
})
const isWithinGracePeriod = (lastValidated: number, days = 7): boolean => {
const elapsed = Date.now() - lastValidated
const gracePeriodMs = days * 24 * 60 * 60 * 1000
return elapsed < gracePeriodMs
}
This pattern follows a three-tier fallback:
- Online validation succeeds — cache the result and launch the app.
- Online validation fails — license is invalid. Show activation.
- Network error — check the cached result. If it is within the grace period, allow offline use. Otherwise, require reactivation.
Step 4 — Implement Offline Validation
Cached timestamps are a reasonable fallback, but they can be circumvented by changing the system clock. For higher-assurance offline validation, Traffic Orchestrator provides verifyOffline() — cryptographic proof that a license was valid at the time of the last server check, using Ed25519 signatures.
// src/main/offline-validation.ts
import { to, store, machineId } from './license'
interface OfflinePayload {
signature: string
payload: string
timestamp: number
}
/**
* Stores the signed offline payload after a successful online validation.
* Call this immediately after to.validate() returns valid: true.
*/
export const cacheOfflineToken = async (licenseKey: string) => {
const offlineToken = await to.createOfflineToken({
licenseKey,
machineId,
ttlDays: 30, // Token valid for 30 days offline
})
store.set('offline_token', {
signature: offlineToken.signature,
payload: offlineToken.payload,
timestamp: Date.now(),
})
}
/**
* Verifies the license offline using Ed25519 signature verification.
* No network call required — validation happens locally.
*/
export const validateOffline = async (): Promise<boolean> => {
const token = store.get('offline_token') as OfflinePayload | undefined
if (!token) return false
try {
const result = await to.verifyOffline({
signature: token.signature,
payload: token.payload,
machineId, // Ensures the token was issued for this machine
})
return result.valid
} catch {
return false
}
}
verifyOffline() can validate the signature without any network access. Clock manipulation does not help because the expiration is embedded in the signed payload.
Update the startup sequence to prefer offline validation when the network is unavailable:
// In your app.whenReady() handler, replace the catch block:
catch (error) {
// Try cryptographic offline validation first
const offlineValid = await validateOffline()
if (offlineValid) {
createMainWindow()
return
}
// Fall back to grace period cache
const cached = store.get('last_validation') as {
valid: boolean
timestamp: number
} | undefined
if (cached?.valid && isWithinGracePeriod(cached.timestamp)) {
createMainWindow()
} else {
createActivationWindow()
}
}
Step 5 — Device Fingerprinting
Raw machine IDs should never be sent directly to the server. Instead, hash them with a salt using HMAC-SHA256 to create a consistent but non-reversible fingerprint:
// src/main/fingerprint.ts
import { createHmac } from 'node:crypto'
import { machineIdSync } from 'node-machine-id'
const FINGERPRINT_SALT = process.env.FINGERPRINT_SALT!
/**
* Generates a hardware-locked fingerprint using HMAC-SHA256.
* The same machine always produces the same fingerprint,
* but the raw machine ID cannot be recovered from the hash.
*/
export const getDeviceFingerprint = (): string => {
const rawId = machineIdSync({ original: true })
return createHmac('sha256', FINGERPRINT_SALT)
.update(rawId)
.digest('hex')
}
/**
* Extended fingerprint combining multiple hardware signals.
* More resistant to VM cloning than machine ID alone.
*/
export const getExtendedFingerprint = (): string => {
const os = require('node:os')
const components = [
machineIdSync({ original: true }),
os.hostname(),
os.cpus()[0]?.model ?? 'unknown',
os.totalmem().toString(),
]
return createHmac('sha256', FINGERPRINT_SALT)
.update(components.join('|'))
.digest('hex')
}
Pass the fingerprint as the machineId parameter in your validation calls:
const result = await to.validate({
licenseKey,
machineId: getDeviceFingerprint(),
})
maxActivations: 3 allows the key to be used on three unique device fingerprints. Attempts to activate on a fourth device are rejected.
Step 6 — Handle Grace Periods
Grace periods determine how long your app continues to function when it cannot reach the validation server. The right duration depends on your customer base:
| Scenario | Recommended Grace Period |
|---|---|
| Consumer app (always-connected) | 3–7 days |
| Developer tool (sometimes offline) | 7–14 days |
| Enterprise / air-gapped environment | 30–90 days (use offline tokens) |
Implement a multi-tier grace period strategy:
// src/main/grace-period.ts
interface GraceConfig {
/** Full functionality period (days) */
fullGrace: number
/** Degraded mode period — core features only (days) */
degradedGrace: number
}
const GRACE_CONFIG: GraceConfig = {
fullGrace: 7,
degradedGrace: 14,
}
type LicenseMode = 'full' | 'degraded' | 'expired'
export const getLicenseMode = (lastValidatedMs: number): LicenseMode => {
const elapsedDays = (Date.now() - lastValidatedMs) / (1000 * 60 * 60 * 24)
if (elapsedDays < GRACE_CONFIG.fullGrace) return 'full'
if (elapsedDays < GRACE_CONFIG.degradedGrace) return 'degraded'
return 'expired'
}
In degraded mode, you might disable premium features like export, cloud sync, or plugin support while keeping core functionality available. This reduces churn from users who temporarily lose connectivity.
Advanced — Floating Licenses
Floating (concurrent) licenses let a team share a pool of seats. When a user closes the app, their seat returns to the pool. This model is standard for enterprise desktop software.
// src/main/floating-license.ts
import { to } from './license'
/**
* Check out a floating license seat.
* The seat is held until explicitly released or the timeout expires.
*/
export const checkoutSeat = async (
licenseKey: string,
userId: string,
) => {
const result = await to.validate({
licenseKey,
type: 'floating',
userId,
timeout: 3600, // Auto-release after 1 hour of inactivity
})
if (result.valid) {
return { success: true, sessionId: result.sessionId }
}
if (result.error === 'seats_exhausted') {
return {
success: false,
reason: 'all_seats_in_use',
activeUsers: result.activeUsers,
totalSeats: result.totalSeats,
}
}
return { success: false, reason: result.error }
}
/**
* Release the seat when the user closes the app.
* Wire this to app.on('before-quit').
*/
export const releaseSeat = async (sessionId: string) => {
await to.releaseSession({ sessionId })
}
Wire seat release to the Electron app lifecycle:
// In your main process
import { releaseSeat } from './floating-license'
let currentSessionId: string | null = null
app.on('before-quit', async (event) => {
if (currentSessionId) {
event.preventDefault()
await releaseSeat(currentSessionId)
currentSessionId = null
app.quit()
}
})
Advanced — VM Detection
Without VM detection, a user can clone a licensed machine image and spin up unlimited instances. Traffic Orchestrator’s VM detection identifies common virtualization platforms and flags validation attempts from virtual environments.
// src/main/vm-detection.ts
import { execSync } from 'node:child_process'
interface VMIndicators {
isVM: boolean
platform: string | null
indicators: string[]
}
/**
* Detects common virtualization environments.
* This is a client-side heuristic — combine with server-side
* validation rules for defense in depth.
*/
export const detectVM = (): VMIndicators => {
const indicators: string[] = []
let platform: string | null = null
try {
// Check for common VM identifiers in system info
const systemInfo = process.platform === 'win32'
? execSync('wmic computersystem get model', { encoding: 'utf8' })
: execSync('hostnamectl', { encoding: 'utf8' })
const vmSignatures = [
'VirtualBox', 'VMware', 'QEMU', 'Hyper-V',
'Xen', 'KVM', 'Parallels', 'Virtual Machine',
]
for (const sig of vmSignatures) {
if (systemInfo.toLowerCase().includes(sig.toLowerCase())) {
indicators.push(sig)
platform = sig
}
}
// Check MAC address prefixes known to VMs
const os = require('node:os')
const interfaces = os.networkInterfaces()
const vmMacPrefixes = [
'08:00:27', // VirtualBox
'00:0c:29', // VMware
'00:1c:42', // Parallels
'00:15:5d', // Hyper-V
]
for (const [, addrs] of Object.entries(interfaces)) {
for (const addr of (addrs as any[])) {
if (addr.mac && vmMacPrefixes.some(p =>
addr.mac.toLowerCase().startsWith(p)
)) {
indicators.push(`VM MAC prefix: ${addr.mac.substring(0, 8)}`)
}
}
}
} catch {
// Detection failed — do not block the user
}
return {
isVM: indicators.length > 0,
platform,
indicators,
}
}
Pass the VM detection result alongside your validation call so the server can enforce your policy:
const vmInfo = detectVM()
const result = await to.validate({
licenseKey,
machineId: getDeviceFingerprint(),
metadata: {
isVM: vmInfo.isVM,
vmPlatform: vmInfo.platform,
},
})
Advanced — Auto-Updates Tied to License
If your app uses electron-updater for auto-updates, gate the update check behind a valid license. This ensures expired or revoked licenses do not continue receiving new versions:
// src/main/updater.ts
import { autoUpdater } from 'electron-updater'
import { to, store } from './license'
import { getDeviceFingerprint } from './fingerprint'
/**
* Check for updates only if the license is currently valid.
* Expired licenses keep their current version but receive
* no new updates until renewed.
*/
export const checkForUpdates = async (licenseKey: string) => {
try {
const result = await to.validate({
licenseKey,
machineId: getDeviceFingerprint(),
})
if (!result.valid) {
console.log('License invalid — skipping update check')
return
}
// License valid — check for updates
autoUpdater.checkForUpdatesAndNotify()
} catch {
// Network error — skip update check silently
// The app continues to function via grace period
}
}
// Configure update feed with license-gated URL
autoUpdater.setFeedURL({
provider: 'generic',
url: 'https://releases.yourapp.com/updates',
headers: {
'X-License-Key': store.get('license_key') as string,
},
})
Security Best Practices
No client-side protection is unbreakable. The goal is to raise the cost of circumvention high enough that purchasing a license is the path of least resistance. Apply these layers in order of impact:
1. Bytecode Compilation
Tools like bytenode or electron-builder’s ASAR encryption compile your JavaScript to raw bytecode. This eliminates readable source from the packaged app:
// build.config.ts
// Compile license-critical modules to bytecode
export const bytecodeModules = [
'src/main/license.ts',
'src/main/fingerprint.ts',
'src/main/offline-validation.ts',
'src/main/vm-detection.ts',
]
2. ASAR Integrity Verification
Electron supports ASAR integrity checking via the asarIntegrity field in your package.json. When enabled, the app verifies the hash of the ASAR archive on startup. Any modification — including patching license checks — invalidates the hash and prevents the app from launching.
3. Code Signing
Sign your application with a valid code signing certificate. On macOS, unsigned apps trigger Gatekeeper warnings. On Windows, unsigned apps trigger SmartScreen. Signing also prevents post-distribution modification of the binary.
4. Disable DevTools in Production
// In your BrowserWindow configuration
const win = new BrowserWindow({
webPreferences: {
devTools: !app.isPackaged,
contextIsolation: true,
nodeIntegration: false,
},
})
5. Native Modules for Sensitive Logic
For maximum protection, implement the license validation kernel as a native Node.js addon (C++ via N-API). Native code is significantly harder to reverse-engineer than JavaScript or bytecode. Use this for the most critical validation paths — the Ed25519 signature check, fingerprint generation, and grace period enforcement.
Putting It All Together
Here is the recommended architecture for a production Electron licensing implementation:
src/main/
├── index.ts // App entry — orchestrates startup flow
├── license.ts // TO client initialization
├── fingerprint.ts // HMAC-SHA256 device fingerprinting
├── offline-validation.ts // Ed25519 offline token management
├── grace-period.ts // Multi-tier grace period logic
├── floating-license.ts // Seat checkout/release for teams
├── vm-detection.ts // Virtualization environment checks
└── updater.ts // License-gated auto-updates
The validation flow on startup:
- Load the license key from the encrypted store.
- Generate the device fingerprint (HMAC-SHA256 of machine ID).
- Attempt online validation via
to.validate(). - On success: cache the result, generate an offline token, launch the app.
- On network failure: try
verifyOffline()with Ed25519 signature check. - On offline token expired: check grace period cache.
- All checks fail: show the activation window.
Licensing Infrastructure for Electron Apps
Traffic Orchestrator handles license key validation, offline activation with Ed25519 signatures, device fingerprinting, floating seats, and VM detection — through one API and 12 published SDKs. The Builder plan is free: 5 licenses, 500 validations/month, full API access.
Create Free AccountShip licensing in your next release
5 licenses, 500 validations/month, full API access. Set up in under 5 minutes — no credit card required.