Tutorial

The Complete Guide to Licensing Your Electron Desktop App

TOT
Traffic Orchestrator Team
Engineering
May 22, 2026 14 min read 2,828 words
Share

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.asar archive, which is a flat file format that anyone can extract with npx @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:

RequirementWhy It Matters
Online validationConfirms the license key is active, not revoked, and within its activation limit — the baseline for any licensing system.
Offline validationDesktop users work on planes, in restricted networks, and behind corporate proxies. You need cryptographic proof that survives without connectivity.
Device fingerprintingBinding a license to specific hardware prevents unauthorized redistribution of keys across unlimited machines.
Grace periodsLocking 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 licensesEnterprise customers need concurrent seat pools — 50 engineers sharing 10 seats across time zones.
Anti-tamperingBytecode compilation, ASAR integrity checks, and code signing raise the cost of circumvention.
VM detectionPrevents 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
Package name: The correct npm package is @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

Security rule: License validation must run in the main process, not the renderer. The renderer process is a Chromium window — users can open DevTools and manipulate any state held there. The main process runs in Node.js and is not directly accessible from the UI.

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-store uses encryptionKey to 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:

  1. Online validation succeeds — cache the result and launch the app.
  2. Online validation fails — license is invalid. Show activation.
  3. 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
  }
}
How Ed25519 offline validation works: When the app is online, it requests a signed token from the Traffic Orchestrator API. The token contains the license key, machine ID, expiration date, and feature entitlements — all signed with the server’s Ed25519 private key. The SDK ships with the corresponding public key, so 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(),
})
Activation limits matter: When you configure a license in Traffic Orchestrator, set the maximum activation count. A license with 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:

ScenarioRecommended Grace Period
Consumer app (always-connected)3–7 days
Developer tool (sometimes offline)7–14 days
Enterprise / air-gapped environment30–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()
  }
})
Plan availability: Floating licenses are available on Professional plans and above (10 seats on Professional, 100 on Business, unlimited on Enterprise).

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,
  },
})
Plan availability: VM and container detection is available on Business plans and above. Configure your policy in the Traffic Orchestrator dashboard — you can block VMs entirely, allow them with logging, or restrict them to specific license tiers.

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:

  1. Load the license key from the encrypted store.
  2. Generate the device fingerprint (HMAC-SHA256 of machine ID).
  3. Attempt online validation via to.validate().
  4. On success: cache the result, generate an offline token, launch the app.
  5. On network failure: try verifyOffline() with Ed25519 signature check.
  6. On offline token expired: check grace period cache.
  7. 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 Account
TOT
Traffic Orchestrator Team
Engineering

The engineering team behind Traffic Orchestrator, building enterprise-grade software licensing infrastructure used by developers worldwide.

Was this article helpful?
Get licensing insights delivered

Engineering deep-dives, security advisories, and product updates. Unsubscribe anytime.

Share this article
Free Plan Available

Ship licensing in your next release

5 licenses, 500 validations/month, full API access. Set up in under 5 minutes — no credit card required.

2-minute setup No credit card Cancel anytime