---
key: "electron-native-security"
title: "Constrain desktop IPC and privileged helpers"
url: "https://softwaresecurity.com/domains/appsec/platform-security/electron-native-security/"
api: "https://softwaresecurity.com/api/v1/articles/electron-native-security.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-749","CWE-346","CWE-940","A01:2025"]
tags: ["Desktop and native apps"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Constrain desktop IPC and privileged helpers

Keep renderer content, local callers and plugins from turning a narrow feature into unrestricted operating-system access.

Canonical URL: https://softwaresecurity.com/domains/appsec/platform-security/electron-native-security/

## 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.

## Recommended defaults

| 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 |

## 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.

## 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.

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

Before you start:

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

Time: About 27 minutes: 8 to read and 19 for the exercise

## 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.

```javascript
// 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.

```javascript
// 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,
  });

```

| 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` |

## 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 material

```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. Accept only the required operation

Evaluate A and list its allowed native effect.

Worked answer: 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. Separate identity from resource authority

Evaluate B even though its sender is trusted.

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

### 3. Constrain message shape and length

Evaluate C and F.

Worked answer: 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. Verify frame and navigation identity

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

Worked answer: 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.

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

Scope: 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.

## Executable practice

- [Stop untrusted pages from using an Electron bridge: project ZIP](https://softwaresecurity.com/downloads/projects/electron-ipc-bridge-af9ef6ea324e1aa6.zip) — Node.js 22.12+ · npm · Electron 44

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [Electron security guidance](https://www.electronjs.org/docs/latest/tutorial/security)
- [The Update Framework specification](https://theupdateframework.github.io/specification/latest/). Version 1.0.37.
- [Electron: context isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation)
- [Electron: inter-process communication](https://www.electronjs.org/docs/latest/tutorial/ipc)
- [Electron: fuses](https://www.electronjs.org/docs/latest/tutorial/fuses)
- [Electron: release timelines and supported versions](https://www.electronjs.org/docs/latest/tutorial/electron-timelines)
- [GitHub advisory GHSA-vmqv-hx8q-j7mg: Electron ASAR integrity bypass](https://github.com/advisories/GHSA-vmqv-hx8q-j7mg)
- [Electron: WebFrameMain (frame.origin)](https://www.electronjs.org/docs/latest/api/web-frame-main)
- [Electron: breaking changes (29.0 ipcRenderer over contextBridge; 42.0 binary download)](https://www.electronjs.org/docs/latest/breaking-changes)
- [Electron release schedule](https://releases.electronjs.org/schedule)
- [GitHub advisory GHSA-qmv3-fv6v-rmhq: Electron sandboxed preload code cache poisoning](https://github.com/electron/electron/security/advisories/GHSA-qmv3-fv6v-rmhq)