Electron Security: Why contextIsolation and contextBridge Are Your Default Choice
Keep Electron's contextIsolation enabled and expose a minimal, typed API via contextBridge. This converts an implicit total-trust boundary into an explicit, auditable surface—preventing XSS from becoming full system compromise.
07 Aug 2025, 01:17 UTC

The Problem: One XSS Becomes Full System Access
Electron's renderer process runs Chromium. If you enable nodeIntegration: true (or disable contextIsolation), any cross-site scripting vulnerability in your web content—or a compromised remote script—immediately gains require('fs'), require('child_process'), and shell access. That's not theoretical; it's the default attack surface when the isolation boundary is removed.
The fix isn't a new tool. Since Electron 12, contextIsolation: true is the default, and the remote module has been removed. The engineering decision is simply: keep isolation on and expose only what you need through a typed, narrow bridge.
How the Boundary Works
With contextIsolation enabled, the preload script executes in a separate JavaScript world (an "isolated world" in Chromium terms). The renderer's page scripts—your React, Vue, or vanilla JS—cannot reach variables or functions defined in preload, and they cannot call require or access Node globals. The only legal crossing is contextBridge.exposeInMainWorld.
This converts an implicit trust boundary ("the renderer can do anything Node can") into an explicit, auditable surface: window.myApi.doSpecificThing(). Every capability is a deliberate line of code you wrote and can review.
Worked Example: A Minimal File-Save Bridge
Below is a complete, minimal pattern. The preload exposes one function; the main process validates and executes; the renderer calls the typed API. This is illustrative code—adapt it and test against your Electron version.
Preload script (preload.ts)
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('appApi', {
saveFile: (content: string, suggestedName: string) =>
ipcRenderer.invoke('file:save', { content, suggestedName }),
});
Main process handler (main.ts)
import { ipcMain, dialog } from 'electron';
import { writeFile } from 'fs/promises';
ipcMain.handle('file:save', async (_event, payload: unknown) => {
// Validate shape — treat renderer input as untrusted
if (
typeof payload !== 'object' ||
payload === null ||
typeof (payload as Record).content !== 'string' ||
typeof (payload as Record).suggestedName !== 'string'
) {
throw new Error('Invalid payload');
}
const { content, suggestedName } = payload as { content: string; suggestedName: string };
// Sanitize filename: allow only safe characters
const safeName = suggestedName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 255);
if (!safeName) throw new Error('Invalid filename');
const { filePath, canceled } = await dialog.showSaveDialog({
defaultPath: safeName,
filters: [{ name: 'Text', extensions: ['txt'] }],
});
if (canceled || !filePath) return { saved: false };
await writeFile(filePath, content, 'utf8');
return { saved: true, path: filePath };
});
Renderer usage (TypeScript with declaration file)
// types/electron.d.ts
declare global {
interface Window {
appApi: {
saveFile(content: string, suggestedName: string): Promise<{ saved: boolean; path?: string }>;
};
}
}
export {};
// In your component
const handleSave = async () => {
const result = await window.appApi.saveFile(editorContent, 'notes.txt');
if (result.saved) toast(`Saved to ${result.path}`);
};
Trade-offs and Limitations
- Boilerplate: Every capability needs a bridge method and a main-process handler. For large apps, consider a code-generation step or a small RPC wrapper to reduce repetition.
- Structured clone only:
contextBridgeuses the structured clone algorithm. Functions are proxied (calls round-trip via IPC), but class instances and some object types do not cross the bridge intact. Pass plain objects, arrays, and primitives. - Wide bridges defeat the purpose: Exposing raw
ipcRendereror a genericsend(channel, ...args)re-creates the implicit trust problem. Keep each bridge method capability-specific.
Verify the Boundary in Your Build
- Run the app with DevTools open on the renderer (
Ctrl+Shift+Ion Windows/Linux,Cmd+Option+Ion macOS). - In the Console, evaluate
typeof require. It should be"undefined". - Evaluate
!!window.appApi?.saveFile. It should betrue. - Call
window.appApi.saveFile('test', 'test.txt')and confirm the save dialog appears and the file is written. - Send a malformed payload over the
file:savechannel (for example, a string instead of an object) and confirm the main-process handler rejects it without crashing.
Check the Electron release notes for your target version to confirm defaults: contextIsolation: true, nodeIntegration: false, and the current sandbox behavior. These defaults have been stable across recent major releases, but always verify against the version you ship, since they have changed historically.
Closing Action
If your BrowserWindow constructor still sets webPreferences: { nodeIntegration: true, contextIsolation: false }, remove those lines. Write one bridge method for the next feature you need, validate its payload in the main process, and run the verification steps above. The security boundary becomes explicit, auditable, and maintainable—without sacrificing the Node capabilities your app requires.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.