In short

Treat renderer content as untrusted and give it a few named operations, never Electron or Node.js APIs. Keep context isolation, the sandbox and nodeIntegration: false; expose functions through contextBridge; and in every ipcMain handler check event.senderFrame, validate the exact payload and authorize the resource. Block navigation and new windows, allowlist shell.openExternal URLs, serve local files from a custom protocol, disable unneeded fuses and stay on a supported Electron release.

  • An XSS in a renderer becomes a desktop compromise if the renderer can reach a general filesystem, shell or IPC API.
  • The preload runs again for every page the window loads, so a bridge stays exposed after navigation; only the sender check stops a foreign page.
  • Check event.senderFrame.origin, as Electron's checklist does, not the URL: the URL of an about:blank, blob: or sandboxed document does not show who controls it.
  • shell.openExternal with an attacker's URL can launch local programs; allow only https: URLs to named hosts.
  • Fuses switch off features at build time: disable RunAsNode and turn on ASAR integrity checks.
  • Electron fixes security bugs only in the three latest majors; on 29 August 2026 it published six advisories, five rated high.

Verify it

  1. Call each bridge method with a valid, a foreign, a malformed and an extra-field payload, and count files written and processes started.
  2. Navigate the window to another origin from the main process and confirm the still-exposed bridge is refused.
  3. From the renderer, set location.href and call window.open with external and file: URLs; nothing may navigate, open or launch.
  4. Run npx @electron/fuses read --app <path> on the packaged app and confirm RunAsNode is disabled.
  5. Compare the bundled Electron version with the latest patch of a supported major before each release.
Applies to
  • Electron The three latest stable majors: 42, 43 and 44 on 11 October 2026, and 43, 44 and 45 from 20 October 2026, when Electron's schedule releases 45.0.0 and ends support for 42; tested on 44.7.0

Desktop IPC and privilege separation. The installed runtime, bridge exposure and update channel affect the boundary.

Part of
Workflow: Review a design

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 27 minutes: 8 to read and 19 for the exercise

What you will learn

  • Design a narrow renderer-to-native capability.
  • Validate sender identity, schema and resource authority separately.
  • Verify denied IPC requests cause zero native side effects.

Before you start

Desktop developers building on Electron or other web-view frameworks, and reviewers of preload bridges, IPC handlers and native helpers.

  • Distinguish renderer content, preload bridges, main-process authority and native helpers.

Refresh first: Review trust boundaries in mobile and native clients

Go to the guided practice

Treat the renderer as a requester of native actions

An Electron renderer displays web content, while the main process and native helpers can possess filesystem, process and operating-system authority. If an XSS in the renderer can send arbitrary native commands, a browser-level bug becomes a desktop compromise; /domains/appsec/browser-security/dom-xss-postmessage/ covers preventing that XSS. The boundary should remain meaningful even when renderer content is hostile. Restrict Node integration in renderer content, use context isolation and appropriate sandboxing, and keep Electron updated; verify the actual supported-version configuration rather than relying on inherited defaults.

Consider exporting an invoice to a user-selected PDF path. The renderer needs a narrow exportInvoice(invoiceId) operation, not readFile(path), run(command) or arbitrary IPC access. The main process validates the request, verifies its sender, checks that the active account may read the invoice and chooses or obtains the output destination through a trusted flow. Hiding a menu item in the renderer does not constrain a compromised renderer's messages.

Expose a small bridge and validate the sender

A preload bridge should publish named operations with typed arguments, not the entire ipcRenderer object or a generic send(channel, payload) function; for this feature the whole preload is contextBridge.exposeInMainWorld('invoices', {exportPdf: id => ipcRenderer.invoke('invoice:export', {invoiceId: String(id)})}). In the main process, validate the sender frame's origin as well as the input shape. A subframe or navigated window should not inherit the authority of the original trusted application page. Exact checks are preferable to string prefixes; app.example.test.attacker.invalid is a different host.

For this export contract, the request contains only invoiceId, and the main process resolves invoice data through the current account's authorized path. The output filename is generated or chosen through the trusted save dialog. The renderer cannot provide a traversal path or executable name. Keep privileged operations behind explicit user interaction where the product requires it, and return only the result the renderer needs. A read-only preview frame should not receive the export capability merely because it is displayed inside the same window. Preloads do not run in subframes by default, but a same-origin iframe can still call parent.invoices, and the handler then sees the trusted top frame as the sender: render untrusted previews in an iframe with the sandbox attribute and without allow-same-origin, or from another origin.

The main-process module below implements the export contract and the window rules around it. trustedSender accepts only the top frame of a document from app://bundle and compares senderFrame.origin, which Chromium reports as app://bundle because registerSchemesAsPrivileged makes the scheme standard; Node's new URL() reports the origin of the same URL as the string null, so isAppUrl compares protocol and host instead. The handler validates the payload's exact shape before authorization, the window refuses navigation to other origins and all new windows, shell.openExternal receives only allowlisted HTTPS URLs, local files come from a custom protocol instead of file://, and every permission request is denied. On 11 October 2026, in Electron 44.7.0 with a harness that recorded shell.openExternal calls, the module exported INV-7 once and refused INV-8 and the nine-digit id; validExportRequest rejected the extra path field, which the one-line preload cannot send at all. The preview iframe had no bridge; the page's CSP blocked an inline script; the navigation handler kept the app page when the renderer set location.href; and no window.open call created a window, while only the allowlisted documentation URL reached shell.openExternal. After the main process loaded a data: page into the same window, the preload exposed window.invoices again, and the handler refused its call. The same-origin preview frame, however, exported INV-7 through parent.invoices, and a sandboxed one could not reach it.

Fixedmain.jsJavaScript
// main.js: the privileged side of the invoice export (Electron 42 to 44).
const {app, BrowserWindow, ipcMain, protocol, net, session, shell} = require('electron');
const path = require('node:path');
const {pathToFileURL} = require('node:url');

const APP_ORIGIN = 'app://bundle';
const DOCS_HOSTS = new Set(['docs.example.test']);
// "standard" gives app://bundle a real origin, which senderFrame.origin reports.
protocol.registerSchemesAsPrivileged([{scheme: 'app', privileges: {standard: true, secure: true}}]);

// For URLs only: Node's WHATWG URL treats app: as opaque, so new URL(...).origin is "null".
function isAppUrl(raw) {
  const url = new URL(raw);
  return url.protocol === 'app:' && url.host === 'bundle';
}

// The sender must be the top frame of a document from our own bundle. Compare the origin, not
// the URL, as Electron's checklist does; senderFrame is null once the frame has gone away.
function trustedSender(frame) {
  return frame != null && frame.parent === null && frame.origin === APP_ORIGIN;
}

function validExportRequest(payload) {
  return payload !== null && typeof payload === 'object' &&
    Object.keys(payload).length === 1 &&
    typeof payload.invoiceId === 'string' && /^INV-[0-9]{1,8}$/.test(payload.invoiceId);
}

function isAllowedExternal(raw) {
  try {
    const url = new URL(raw);
    return url.protocol === 'https:' && DOCS_HOSTS.has(url.hostname) && !url.username;
  } catch {
    return false;
  }
}

// deps: canRead(invoiceId), chooseDestination() (a trusted save dialog), writePdf(id, file).
function registerExport(deps) {
  ipcMain.handle('invoice:export', async (event, payload) => {
    if (!trustedSender(event.senderFrame)) return {status: 'denied'};
    if (!validExportRequest(payload)) return {status: 'invalid'};
    if (!deps.canRead(payload.invoiceId)) return {status: 'denied'};
    const file = await deps.chooseDestination();
    if (!file) return {status: 'canceled'};
    await deps.writePdf(payload.invoiceId, file);
    return {status: 'saved'};
  });
}

function createWindow() {
  const win = new BrowserWindow({
    show: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true, // the defaults, stated so a review can see them
      sandbox: true,
      nodeIntegration: false,
    },
  });
  win.webContents.on('will-navigate', event => {
    if (!isAppUrl(event.url)) event.preventDefault();
  });
  win.webContents.setWindowOpenHandler(({url}) => {
    if (isAllowedExternal(url)) setImmediate(() => shell.openExternal(url));
    return {action: 'deny'};
  });
  return win;
}

function serveBundle(dir) {
  // Serve only files under dir through app://bundle/ instead of file://.
  protocol.handle('app', request => {
    const {host, pathname} = new URL(request.url);
    const file = path.join(dir, path.normalize(decodeURIComponent(pathname)));
    if (host !== 'bundle' || !file.startsWith(dir + path.sep)) return new Response(null, {status: 404});
    return net.fetch(pathToFileURL(file).toString());
  });
  session.defaultSession.setPermissionRequestHandler((_wc, _permission, callback) => callback(false));
}

module.exports = {createWindow, registerExport, serveBundle, trustedSender, validExportRequest, isAllowedExternal, app};

Work through Electron's security checklist

Electron's own checklist has 20 items. The table gives the setting or API for each; items 13 to 20 are the ones most often missing from reviewed apps. Fuses, item 19, are flags compiled into the Electron binary that turn whole features off. The script below flips them after packaging and before code signing; on a copy of Electron 44.7.0, ELECTRON_RUN_AS_NODE=1 ran Node.js code before the change and was ignored after it. npx @electron/fuses read --app <path> shows the current state.

Work through Electron's security checklist
ItemWhat to do
1. Only load secure contentHTTPS for remote resources; the local UI from a custom protocol
2. No Node.js integration for remote contentnodeIntegration: false, the default
3. Context isolation in all rendererscontextIsolation: true, the default since Electron 12
4. Process sandboxingsandbox: true, the default for renderers since Electron 20
5. Permission request handlerssession.setPermissionRequestHandler denying by default
6. Keep webSecurityNever set webSecurity: false
7. Content Security PolicyAt least script-src 'self', sent as a header or meta tag
8 to 10. Insecure and experimental featuresLeave allowRunningInsecureContent, experimentalFeatures and enableBlinkFeatures off
11 and 12. <webview>Avoid allowpopups; check options and parameters in will-attach-webview
13. Limit navigationwill-navigate handler that prevents other origins
14. Limit new windowssetWindowOpenHandler returning {action: 'deny'}
15. shell.openExternalOnly https: URLs to named hosts, never a renderer-supplied string as is
16. Current ElectronThe latest patch of one of the three latest stable majors (42 to 44 on 11 October 2026; 43 to 45 from the scheduled 45.0.0 release on 20 October 2026)
17. Validate IPC sendersevent.senderFrame.origin checked in every handler
18. Custom protocol instead of file://protocol.handle serving one directory
19. Fuses@electron/fuses, as in the script below
20. Do not expose Electron APIsNamed functions through contextBridge, never ipcRenderer or require
Configurationfuses.jsJavaScript
// fuses.js: run after packaging, before signing (@electron/fuses 2.x).
const {flipFuses, FuseVersion, FuseV1Options} = require('@electron/fuses');

module.exports = electronBinary =>
  flipFuses(electronBinary, {
    version: FuseVersion.V1,
    resetAdHocDarwinSignature: process.platform === 'darwin' && process.arch === 'arm64',
    [FuseV1Options.RunAsNode]: false, // ELECTRON_RUN_AS_NODE no longer turns the app into Node
    [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
    [FuseV1Options.EnableNodeCliInspectArguments]: false,
    [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
    [FuseV1Options.OnlyLoadAppFromAsar]: true,
    [FuseV1Options.EnableCookieEncryption]: true,
    [FuseV1Options.GrantFileProtocolExtraPrivileges]: false,
  });

Apply the same rule to native helpers and updates

A separate privileged helper is another server. Its caller identity and allowed operation must be verified independently of the GUI. Use operating-system IPC authentication and narrowly scoped privileges; do not accept arbitrary paths or shell commands from an unprivileged process. Canonicalizing a path once can leave a race if another actor can replace a directory before the helper opens it. Prefer controlled destinations and safe handle-based operations where the platform supports them.

Native code also needs memory and resource safety. Bounds checks, safe ownership patterns and sanitizer-based testing address defects that an IPC allowlist cannot. Updates must authenticate released code and maintain a trustworthy update path; a secure bridge does not help if an attacker can replace the application through an unauthenticated updater; /domains/supply-chain/releases/trusted-publishing-and-updates/ covers signed update channels. Plan rollback and recovery carefully so a failure does not encourage users or support tools to disable signature verification. Keep the runtime itself current: Electron publishes fixes only for its three latest majors, and the advisories it published on 29 August 2026 included a sandboxed-preload code-cache poisoning bug (CVE-2026-102677) fixed in 42.10.0, 43.5.0 and 44.0.0-beta.6.

Test the capability without relying on the ordinary UI

Invoke the handler with a valid invoice id, a foreign invoice, an extra path field, a malformed id and an unexpected sender. For denied requests, assert zero files created and zero subprocesses launched. A generic error after a file was written is too late. For a normal allowed export, assert one PDF with the expected synthetic invoice data in the chosen location.

Test navigation and frame transitions: a trusted frame that navigates to external content must lose the privileged operation, and an embedded untrusted frame must not impersonate its parent. Observe effective webPreferences, fuse states and actual bridge exposure in the packaged build, not only development source. Debug failures using operation name, sender classification and request id, with bounded payload metadata. Do not log complete invoices, filesystem contents or session credentials merely to understand an IPC denial. The electron-ipc-bridge project below turns these checks into tests: Playwright drives the real app in Electron, a local page plays the untrusted content, and each exploit test counts the files written and the links handed to the operating system.

Make sender checks survive application navigation

A trusted BrowserWindow is not a permanent identity for all content it will ever display. Navigation can replace its document, and child frames may host different origins. An IPC handler that checks only a window id can continue honoring requests after the window leaves the application. Couple the capability to the expected frame and current trusted document, and restrict navigation or window creation according to the product's requirements. The request must fail if any of those premises no longer holds.

Do not return an unrestricted callback or event object through the preload bridge. Such objects can expose a larger API surface than the named method suggests. Translate the response into a small data record and keep native objects on the privileged side. Validate outputs as well when a helper returns a path or command result that will be rendered as HTML or reused in another native operation; crossing one boundary safely does not authorize every later interpretation.

For the invoice export, a canceled save dialog should produce canceled with zero files, not fall back to a renderer-supplied default path. A write failure should preserve a bounded error and remove only the operation's own partial output. Test cancellation, permission-denied writes and a destination that becomes unavailable after selection. These are ordinary desktop states, and explicit handling prevents support-oriented fallback code from widening the bridge's authority when the happy path fails.

Guided practice

Evaluate a complete native export contract against supplied IPC calls.

Supplied scenario material
Plain text
Active account=Alice; owns INV-7; INV-8 belongs to Bob.
Allowed sender=(windowW,main frame,https://app.example.test/). Preview frame=P; other window=Q.
Payload allows only invoiceId matching INV-[0-9]{1,8}; no path field. Destination chosen by trusted save dialog as /chosen/invoice.pdf.
A: trusted sender,{invoiceId:INV-7}.
B: trusted sender,{invoiceId:INV-8}.
C: trusted sender,{invoiceId:INV-7,path:../../outside}.
D: frameP,{invoiceId:INV-7}.
E: windowW main frame after navigation to https://outside.invalid/,{invoiceId:INV-7}.
F: trusted sender,{invoiceId:INV-123456789}.
All cases independent; filesWritten=0; processesStarted=0.
  1. 1. Accept only the required operation

    Evaluate A and list its allowed native effect.

    Expected result and reasoning

    A may write one generated INV-7 PDF to the trusted dialog destination after authorization. It starts zero subprocesses under this contract and exposes no general filesystem API.

  2. 2. Separate identity from resource authority

    Evaluate B even though its sender is trusted.

    Expected result and reasoning

    B denies because Alice does not own INV-8. Trusted UI origin is insufficient to authorize a particular invoice. filesWritten remains 0.

  3. 3. Constrain message shape and length

    Evaluate C and F.

    Expected result and reasoning

    C rejects the unknown path field before any write; the renderer cannot choose ../../outside. F rejects a nine-digit id because the contract permits at most eight. Both leave filesWritten=0.

  4. 4. Verify frame and navigation identity

    Evaluate D and E, and explain why checking only windowW would be incomplete.

    Expected result and reasoning

    D denies the preview subframe. E denies the navigated origin despite retaining the window identity. The authorized sender must satisfy frame and current location requirements together; neither can be inferred from the visible UI.

Your deliverable: An IPC permission matrix with exact native-effect counts and a minimal bridge contract.

Paper exercise: identities, frames and payloads are supplied. In Electron 44.7.0 the main.js module above ran A, B and F through the real bridge, rejected C in validExportRequest, gave the preview frame no bridge and refused a foreign page loaded into the window.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Node.js 22.12+ · npm · Electron 44 · 60–90 min

Stop untrusted pages from using an Electron bridge

Run a real Electron notes app whose preload passes any IPC channel through and whose main process trusts every sender. Repair the sandbox, the contextBridge API, the sender and payload checks, navigation and window rules, the CSP and permissions until a link, a foreign page or injected script can no longer write files or launch programs.

Needs: Internet access for npm ci and for the Electron 44.7.0 download (about 130 MB on macOS), which is checked against the SHA-256 sums in the npm package. The Electron tests need a desktop session, or Xvfb on Linux; the 24 fast tests need only Node.js.

In the extracted folder, run:

Shell
npm ci --ignore-scripts
npx install-electron --no
npm test
npm run test:e2e

33 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.

24 policy and preload tests run in plain Node.js; 9 exploit tests drive the real app in Electron 44.7.0 with Playwright. The starter failed 8 of those 9 and the reference passed all 9, on macOS and in the Linux dev container under Xvfb. A development build: fuses, code signing and packaging are not covered.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. Electron security guidance
  2. The Update Framework specification. Version 1.0.37.
  3. Electron: context isolation
  4. Electron: inter-process communication
  5. Electron: fuses
  6. Electron: release timelines and supported versions
  7. GitHub advisory GHSA-vmqv-hx8q-j7mg: Electron ASAR integrity bypass
  8. Electron: WebFrameMain (frame.origin)
  9. Electron: breaking changes (29.0 ipcRenderer over contextBridge; 42.0 binary download)
  10. Electron release schedule
  11. GitHub advisory GHSA-qmv3-fv6v-rmhq: Electron sandboxed preload code cache poisoning

Reference information

SDLC phase
Development

Download this guide as Markdown