Application Security

Electron ContextBridge Security: Building Safe Desktop Apps

Electron's ContextBridge is the secure boundary between web content and Node.js APIs. This guide covers how to use it correctly, common mistakes that create RCE vulnerabilities, and security best practices for Electron applications.

Shadab Khan
DevSecOps Engineer
6 min read

Electron lets you build desktop applications with web technologies. That flexibility comes with a fundamental security tension: web content runs in a renderer process that can potentially access Node.js APIs, and Node.js APIs mean full system access. A cross-site scripting vulnerability in a web application is bad. A cross-site scripting vulnerability in an Electron application with Node.js access is remote code execution.

ContextBridge is Electron's answer to this problem. It provides a controlled boundary between the untrusted renderer process (web content) and the privileged main process (Node.js). Used correctly, ContextBridge limits what the renderer can do even if an attacker achieves JavaScript execution. Used incorrectly, it provides a false sense of security while leaving the application wide open.

The Problem ContextBridge Solves

In early Electron applications, the common pattern was to enable nodeIntegration in the renderer process. This gave web content direct access to require('child_process'), require('fs'), and every other Node.js module. Any XSS vulnerability in the renderer instantly became remote code execution.

The solution was contextIsolation -- running the renderer's web content in a separate JavaScript context from the preload script. The web page cannot access Node.js APIs directly because they exist in a different context. ContextBridge provides a controlled API for the preload script to expose specific functions to the web content.

Correct ContextBridge Usage

The preload script runs in a privileged context with access to Node.js APIs. It uses ContextBridge to expose specific, validated functions to the renderer:

// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('myAPI', {
  readFile: (filename) => {
    // Validate the filename before passing to IPC
    if (!isValidFilename(filename)) throw new Error('Invalid filename');
    return ipcRenderer.invoke('read-file', filename);
  },
  getVersion: () => ipcRenderer.invoke('get-version')
});

The renderer accesses these functions through the exposed API:

// renderer.js (web content)
const content = await window.myAPI.readFile('config.json');
const version = await window.myAPI.getVersion();

The main process handles the IPC calls with its own validation:

// main.js
ipcMain.handle('read-file', async (event, filename) => {
  // Validate again in the main process
  const safePath = path.resolve(allowedDir, path.basename(filename));
  if (!safePath.startsWith(allowedDir)) throw new Error('Path traversal');
  return fs.readFile(safePath, 'utf-8');
});

Critical Security Settings

Every Electron application should set these options:

const win = new BrowserWindow({
  webPreferences: {
    contextIsolation: true,      // Isolate preload from renderer
    nodeIntegration: false,       // No Node.js in renderer
    nodeIntegrationInWorker: false,
    nodeIntegrationInSubFrames: false,
    sandbox: true,                // OS-level sandbox for renderer
    preload: path.join(__dirname, 'preload.js'),
    webSecurity: true,            // Enforce same-origin policy
    allowRunningInsecureContent: false,
    experimentalFeatures: false,
  }
});

contextIsolation: true is non-negotiable. Without it, the renderer and preload share a JavaScript context, and prototype pollution or object manipulation in the renderer can access Node.js APIs through the preload's scope.

nodeIntegration: false ensures that even if context isolation fails, the renderer does not have direct access to require().

sandbox: true enables Chromium's OS-level sandbox for the renderer process, restricting system calls even if code execution is achieved.

Common Mistakes

Exposing ipcRenderer directly. The most dangerous mistake:

// DANGEROUS: exposes all IPC channels
contextBridge.exposeInMainWorld('electron', {
  ipcRenderer: ipcRenderer
});

This gives the renderer access to every IPC channel, including ones that were only intended for internal use. The renderer can call window.electron.ipcRenderer.send('any-channel', 'any-data') to trigger any main-process handler.

Exposing generic send/invoke functions:

// DANGEROUS: renderer can call any channel
contextBridge.exposeInMainWorld('api', {
  send: (channel, data) => ipcRenderer.send(channel, data),
  invoke: (channel, data) => ipcRenderer.invoke(channel, data)
});

This is functionally equivalent to exposing ipcRenderer directly. The renderer chooses which channel to call.

Safe alternative:

// SAFE: only specific channels are accessible
const allowedChannels = ['read-file', 'get-version', 'save-settings'];

contextBridge.exposeInMainWorld('api', {
  invoke: (channel, data) => {
    if (!allowedChannels.includes(channel)) {
      throw new Error(`Channel ${channel} is not allowed`);
    }
    return ipcRenderer.invoke(channel, data);
  }
});

Even better -- expose individual named functions instead of a generic channel dispatcher.

Missing input validation. The preload script must validate all arguments before passing them to IPC. The main process must validate again. Defense in depth:

// preload.js
contextBridge.exposeInMainWorld('files', {
  read: (filename) => {
    if (typeof filename !== 'string') throw new TypeError('Expected string');
    if (filename.includes('..')) throw new Error('Path traversal');
    if (filename.length > 255) throw new Error('Filename too long');
    return ipcRenderer.invoke('read-file', filename);
  }
});

Exposing Node.js objects. Passing Node.js objects (Buffers, Streams, EventEmitters) through ContextBridge can leak access to Node.js internals. Always serialize data to simple types (strings, numbers, plain objects, arrays) before exposing through the bridge.

Renderer-to-Main Security

The IPC communication between renderer and main process needs its own security model:

Validate the sender. The main process should verify that IPC messages come from expected windows:

ipcMain.handle('sensitive-action', async (event, data) => {
  // Verify the sender is our main window
  if (event.sender !== mainWindow.webContents) {
    throw new Error('Unauthorized sender');
  }
  // Process the request
});

Minimize exposed channels. Each IPC channel is an attack surface. Expose only what the renderer needs, and nothing more.

Rate limit IPC calls. A compromised renderer could flood the main process with IPC messages. Rate limiting prevents denial of service.

Do not trust renderer state. The main process should never trust data from the renderer for authorization decisions. The renderer is untrusted -- always verify permissions in the main process.

Deep Linking and Protocol Handlers

Electron applications often register custom protocol handlers (myapp://). These are a significant attack surface:

// Validate all deep link URLs
app.on('open-url', (event, url) => {
  event.preventDefault();
  const parsed = new URL(url);
  if (parsed.protocol !== 'myapp:') return;
  // Validate the path and parameters strictly
  // Never pass deep link data to shell.openExternal or child_process
});

Never pass deep link URLs to shell.openExternal without validation. An attacker can craft myapp://... URLs that trick the application into opening arbitrary URLs or executing commands.

Content Loading Security

Remote content. Loading remote web content in an Electron application combines the risks of a web browser with the risks of Node.js access. If you must load remote content:

  • Use session.setPermissionRequestHandler to control what permissions remote content receives
  • Set a strict Content Security Policy
  • Disable navigation to unexpected origins
  • Never enable nodeIntegration for windows loading remote content

Local content. Even local content (bundled HTML/JS) can be vulnerable if it includes user-generated content, renders untrusted markdown, or loads remote resources.

WebView and BrowserView. If using <webview> tags or BrowserViews, apply the same security settings as BrowserWindows. Each one is a separate renderer with its own security context.

Update Security

Electron applications that auto-update must verify update authenticity:

  • Use code signing on all platforms
  • Verify update signatures before installation
  • Use HTTPS for update downloads
  • Implement certificate pinning for the update server
  • Use Electron's built-in autoUpdater with a signed update feed

A compromised update mechanism gives the attacker code execution on every installed instance of your application.

How Safeguard.sh Helps

Safeguard.sh monitors the Electron framework version and native Node.js modules in your desktop application's dependency tree. Electron CVEs are published regularly -- Chromium vulnerabilities, Node.js vulnerabilities, and Electron-specific issues like context isolation bypasses. Safeguard.sh tracks these against your SBOM and alerts when your Electron version or its dependencies have known security issues. For desktop applications where users cannot be forced to update immediately, this visibility is critical for understanding your exposure window and prioritizing updates.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.