Controlling Electron's Renderer Attack Surface with contextBridge
Electron's contextBridge lets you expose a narrow, audited API from the main process to the renderer. Here is how to replace nodeIntegration with an explicit IPC contract, with a worked file-read example.
04 Feb 2026, 04:01 UTC

The problem: one XSS away from remote code execution
When an Electron renderer loads remote content with nodeIntegration: true, a single cross-site scripting flaw in that content can escalate into remote code execution. The renderer has direct access to Node.js globals such as require, process, and fs, so an attacker who injects script can read local files, spawn processes, or exfiltrate data. The historical fix—disabling Node integration everywhere—often broke legitimate apps that needed file system access or native modules.
The thesis: an explicit, audited bridge
Introduced in Electron 7, contextBridge changes the contract. Instead of handing the renderer the entire Node.js runtime, you run a preload script that retains Node access and use contextBridge.exposeInMainWorld to publish a small, named API onto the renderer's window object. Combined with contextIsolation: true, the renderer cannot reach the preload's scope directly; it only sees the functions you explicitly expose. This turns a broad attack surface into a narrow, reviewable one.
How the pieces fit together
Three settings matter in webPreferences:
nodeIntegration: false— removes Node globals from the renderer.contextIsolation: true— runs the preload and the renderer in separate JavaScript contexts, so the renderer cannot mutate the preload's objects.sandbox: true— restricts the preload to a limited Node environment, reducing the impact of a preload compromise.
The preload script is the only place with privileged access. It communicates back to the main process through ipcRenderer, typically using invoke/handle for request-response patterns. While synchronous bridges using sendSync are possible, they block the renderer thread; the invoke pattern shown below is preferred for I/O.
Worked example: a file-read helper
The following example assumes Electron 28 or later. It exposes a single asynchronous function, readTextFile, that the renderer can call. The main process performs the actual file read and returns the contents.
Main process
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('node:path');
const fs = require('node:fs/promises');
ipcMain.handle('read-text-file', async (event, filePath) => {
// In a real app, validate filePath against an allow-list.
return fs.readFile(filePath, 'utf8');
});
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true
}
});
win.loadURL('https://example.com');
});
Preload script
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
readTextFile: (filePath) => ipcRenderer.invoke('read-text-file', filePath)
});
Renderer (React component)
function FileViewer() {
const [content, setContent] = React.useState('');
const load = async () => {
const text = await window.api.readTextFile('/etc/hosts');
setContent(text);
};
return (
<button onClick={load}>Read hosts file</button>
);
}
Because nodeIntegration is false, the renderer cannot call require('fs') directly. It can only call window.api.readTextFile, which forwards the request to the main process. If the renderer is compromised, the attacker is limited to the arguments and return values of that single function.
Trade-offs and limitations
You must design the API surface. Every exposed function is a potential channel for abuse. A file-read helper that accepts arbitrary paths is dangerous; validate inputs against an allow-list or resolve them against a known base directory. The bridge does not sanitize arguments for you.
Preload errors are fatal. The preload runs before any renderer code. An uncaught exception there can leave the renderer without its expected API or crash the page. Wrap initialization in defensive checks.
Performance has a cost. Each invoke call serializes arguments and crosses a process boundary. A handful of calls is negligible, but a tight loop making thousands of IPC calls per second will add measurable latency. Batch requests or move hot paths into the main process when needed.
It is not a complete security posture. contextBridge reduces the blast radius of renderer compromise, but it does not replace a Content Security Policy, secure loadURL sources, or OS-level sandboxing. Treat it as one layer.
Actionable takeaways
- Default to
nodeIntegration: false,contextIsolation: true, andsandbox: truein new windows. - Keep preload scripts minimal: expose only the functions the renderer genuinely needs.
- Validate every argument that crosses the bridge, especially file paths and URLs.
- Audit
exposeInMainWorldcalls during code review; ask what an attacker could do with each exposed method.
To verify your setup, run the app with the settings above and confirm in the renderer DevTools that window.require is undefined while window.api exists. If require is present, nodeIntegration or contextIsolation is misconfigured.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.