Designing the IPC Trust Boundary in an Electron App
An architecture note on Electron's main/renderer split: the smallest preload bridge, validating IPC payloads in the main process, navigation lockdown, and the checks that prove the boundary holds.
17 Apr 2026, 16:31 UTC

An Electron app that loads any HTML — even HTML you wrote — is one XSS bug away from arbitrary code execution on the user's machine. The renderer runs Chromium, and Chromium bugs plus your own DOM mistakes are a matter of when, not if. The architecture decision that limits the blast radius is the split between the privileged main process and the sandboxed renderer, with a preload script as the only door between them. This note covers the requirements, the smallest design that satisfies them, and how to check that the boundary actually holds.
Requirements
The main process has full Node.js: filesystem, child processes, network, native modules. The renderer should have none of that. Concretely:
- The renderer cannot import Node or Electron modules directly.
- The renderer can request a small, enumerated set of operations (read a config file, save a document, open a URL in the system browser).
- Every request crossing the boundary is validated in the main process before it touches anything privileged.
- A compromised renderer cannot navigate itself to attacker-controlled content or open new privileged windows.
Window configuration: the foundation
Every BrowserWindow should be created with explicit webPreferences. Do not rely on defaults — they have changed across Electron versions, so pin behavior explicitly and verify against the version in your package.json:
new BrowserWindow({
webPreferences: {
contextIsolation: true, // preload runs in a separate JS world
nodeIntegration: false, // no require() in the renderer
sandbox: true, // renderer is an OS-level sandboxed process
preload: path.join(__dirname, 'preload.js'),
},
});contextIsolation: true means the preload script runs in a separate JavaScript context from the page. The page cannot reach the preload's scope, so it cannot tamper with the functions that talk to the main process. sandbox: true additionally restricts the renderer at the OS level, which matters most if you ever load content you did not bundle.
The preload: the smallest possible bridge
The preload script is the only code that sees both worlds. It should expose a narrow, whitelisted API via contextBridge.exposeInMainWorld, wrapping ipcRenderer.invoke calls. The anti-pattern to avoid is exposing anything generic — a raw ipcRenderer, an "invoke any channel" helper, or require itself:
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('desktop', {
readSettings: () => ipcRenderer.invoke('settings:read'),
saveDocument: (name, contents) =>
ipcRenderer.invoke('doc:save', { name, contents }),
openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url),
});The page sees only window.desktop with three methods. It cannot invent channel names, because the channel strings live in the preload, not in page-reachable code.
The main process: treat every payload as hostile
Pair each channel with an ipcMain.handle handler that validates before acting. The renderer is untrusted input, exactly like a web client talking to a server API:
const { ipcMain, shell } = require('electron');
ipcMain.handle('doc:save', async (event, payload) => {
if (typeof payload !== 'object' || payload === null) {
throw new Error('invalid payload');
}
const { name, contents } = payload;
if (typeof name !== 'string' || !/^[\w.-]{1,100}$/.test(name)) {
throw new Error('invalid name');
}
if (typeof contents !== 'string' || contents.length > 5_000_000) {
throw new Error('invalid contents');
}
await fs.writeFile(path.join(docsDir, name), contents, 'utf8');
});
ipcMain.handle('shell:openExternal', async (event, url) => {
if (typeof url !== 'string' || !url.startsWith('https://')) {
throw new Error('invalid url');
}
await shell.openExternal(url);
});Note the details: the filename is matched against an allowlist pattern so ../../etc/passwd cannot escape docsDir; sizes are bounded; the URL scheme is restricted so a compromised renderer cannot pass a file:// or custom protocol handler to the OS shell.
Navigation and window-open: the second boundary
Even a perfect bridge fails if the renderer can navigate itself to attacker content, because that content then runs with your preload attached. Restrict both navigation and new windows in the main process:
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
win.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith('file://')) event.preventDefault();
});If you genuinely need remote or third-party content — a help site, an OAuth page — put it in a separate BrowserWindow or BrowserView with its own session partition, no preload, and sandbox: true. Never load remote content into the window that carries the privileged bridge; that converts any remote-content injection into full RCE.
Operational checks
Verify the boundary rather than assuming the config is right:
- Static audit: grep the codebase for every
BrowserWindow/BrowserViewand confirmcontextIsolation: true,nodeIntegration: false, and nowebSecurity: falseorallowRunningInsecureContent. Those last two silence errors by dismantling the boundary — treat them as design smells. - Runtime probe: open DevTools in the renderer and confirm
window.require,window.process, andwindow.ipcRendererare allundefined, and that only your bridged API exists. - Negative tests: from DevTools, call each bridged method with wrong types, oversized strings, and extra fields, and confirm the main process rejects them rather than throwing unhandled exceptions or, worse, partially succeeding.
- Checklist pass: run through Electron's published security checklist for your pinned version: CSP meta tag in your HTML, a permission request handler that denies by default, and a current Electron version.
Failure modes and when the design changes
The realistic failure mode is not a broken sandbox — it is bridge creep. Each new feature tempts someone to add a broader channel: "just pass the path through," "just expose exec for this one tool." Every bridge method is an attack-surface decision and belongs in code review with the same scrutiny as a public API endpoint.
Revisit the design when: you start loading remote content (separate window, no bridge); you need plugins or third-party extensions (they need their own sandboxed process model, not bridge access); or you find yourself wanting a generic "run this" channel (that is the signal the feature belongs in the main process with a fixed command allowlist, not in the renderer).
The boundary is cheap to build and expensive to retrofit. Keep the bridge small, validate in main, and verify with the runtime probes above after every Electron upgrade.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.