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 anabout:blank,blob:or sandboxed document does not show who controls it. shell.openExternalwith an attacker's URL can launch local programs; allow onlyhttps:URLs to named hosts.- Fuses switch off features at build time: disable
RunAsNodeand 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.
| Setting | Value | Note | Source |
|---|---|---|---|
webPreferences | contextIsolation: true, sandbox: true, nodeIntegration: false | The defaults since Electron 12 (context isolation) and 20 (sandbox); never turn them off for remote content. | Electron security checklist, items 2 to 4 |
| IPC handlers | Check that event.senderFrame is a top frame whose origin is your own, then validate the payload schema | — | Electron security checklist, item 17 |
| Navigation and windows | will-navigate prevents other origins; setWindowOpenHandler returns {action: 'deny'} | — | Electron security checklist, items 13 and 14 |
| Permissions | session.setPermissionRequestHandler denies by default | — | Electron security checklist, item 5 |
| Fuses | RunAsNode, EnableNodeOptionsEnvironmentVariable and EnableNodeCliInspectArguments off; EnableEmbeddedAsarIntegrityValidation and OnlyLoadAppFromAsar on | ASAR integrity needs a patched Electron: CVE-2025-55305 bypassed it before 35.7.5, 36.8.1 and 37.3.1. | Electron fuses documentation; GHSA-vmqv-hx8q-j7mg |
| Electron version | The latest patch of one of the three latest stable majors: 42, 43 or 44 on 11 October 2026; 43, 44 or 45 from 20 October 2026, the scheduled 45.0.0 release date | — | Electron release schedule |
- Call each bridge method with a valid, a foreign, a malformed and an extra-field payload, and count files written and processes started.
- Navigate the window to another origin from the main process and confirm the still-exposed bridge is refused.
- From the renderer, set
location.hrefand callwindow.openwith external andfile:URLs; nothing may navigate, open or launch. - Run
npx @electron/fuses read --app <path>on the packaged app and confirmRunAsNodeis disabled. - 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
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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 practiceAn 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.
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.
// 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};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.
| Item | What to do |
|---|---|
| 1. Only load secure content | HTTPS for remote resources; the local UI from a custom protocol |
| 2. No Node.js integration for remote content | nodeIntegration: false, the default |
| 3. Context isolation in all renderers | contextIsolation: true, the default since Electron 12 |
| 4. Process sandboxing | sandbox: true, the default for renderers since Electron 20 |
| 5. Permission request handlers | session.setPermissionRequestHandler denying by default |
6. Keep webSecurity | Never set webSecurity: false |
| 7. Content Security Policy | At least script-src 'self', sent as a header or meta tag |
| 8 to 10. Insecure and experimental features | Leave allowRunningInsecureContent, experimentalFeatures and enableBlinkFeatures off |
11 and 12. <webview> | Avoid allowpopups; check options and parameters in will-attach-webview |
| 13. Limit navigation | will-navigate handler that prevents other origins |
| 14. Limit new windows | setWindowOpenHandler returning {action: 'deny'} |
15. shell.openExternal | Only https: URLs to named hosts, never a renderer-supplied string as is |
| 16. Current Electron | The 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 senders | event.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 APIs | Named functions through contextBridge, never ipcRenderer or require |
// 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,
});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.
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.
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
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. Accept only the required operation
Evaluate A and list its allowed native effect.
Expected result and reasoning
A may write one generated
INV-7PDF to the trusted dialog destination after authorization. It starts zero subprocesses under this contract and exposes no general filesystem API.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.filesWrittenremains 0.3. Constrain message shape and length
Evaluate C and F.
Expected result and reasoning
C rejects the unknown
pathfield before any write; the renderer cannot choose../../outside. F rejects a nine-digit id because the contract permits at most eight. Both leavefilesWritten=0.4. Verify frame and navigation identity
Evaluate D and E, and explain why checking only
windowWwould 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.
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.
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:
npm ci --ignore-scripts
npx install-electron --no
npm test
npm run test:e2e33 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.
- Electron security guidance
- The Update Framework specification. Version 1.0.37.
- Electron: context isolation
- Electron: inter-process communication
- Electron: fuses
- Electron: release timelines and supported versions
- GitHub advisory GHSA-vmqv-hx8q-j7mg: Electron ASAR integrity bypass
- Electron: WebFrameMain (frame.origin)
- Electron: breaking changes (29.0 ipcRenderer over contextBridge; 42.0 binary download)
- Electron release schedule
- GitHub advisory GHSA-qmv3-fv6v-rmhq: Electron sandboxed preload code cache poisoning
Reference information
- SDLC phase
- Development