Electron Context Isolation: Building Secure IPC with contextBridge
Electron 20+ mandates contextIsolation and removes the remote module. This blog walks through the secure IPC pattern using contextBridge, shows a typed preload/renderer/main example, and explains the structured-clone constraints that shape API design.
15 Jul 2026, 05:19 UTC

The Migration Problem Every Electron Team Faces
If you've maintained an Electron app since before version 12, you've lived through the nodeIntegration: true and remote era. That pattern — requiring Node.js modules directly in renderer processes — is gone. Electron 20 made contextIsolation: true mandatory, and the remote module was fully removed in Electron 14. The migration isn't optional anymore, and the new model changes how you architect the boundary between privileged and untrusted code.
What Changed and Why It Matters
Context Isolation separates the renderer's JavaScript context from the preload script's context. The preload script runs with full Node.js and Electron API access (the "privileged" side), while the renderer runs in a sandboxed, isolated context (the "untrusted" side). They cannot share prototypes, globals, or object references directly. This prevents prototype pollution attacks and stops a compromised renderer from reaching require('fs') or require('child_process').
The only supported bridge is contextBridge. You call contextBridge.exposeInMainWorld('apiName', { ... }) in the preload script, and the renderer accesses it via window.apiName. Objects passed across this boundary are frozen and cloned via structured clone — functions become callable proxies, plain objects are deep-cloned, and prototypes are stripped. This serialization behavior is not optional; it's the security model.
How contextBridge Actually Works
Understanding the serialization rules saves debugging time. Only these types survive the crossing intact:
- Primitives (string, number, boolean, null, undefined, bigint, symbol)
- Plain objects and arrays (deep-cloned, prototypes removed)
- Callable functions (become proxies that invoke the original in the preload context)
Everything else throws or loses behavior: class instances become plain objects, Promises become plain objects (losing .then()), functions with closure state lose that state, DOM nodes throw, and circular references throw. This means your API surface must be designed around these constraints — no passing React components, no passing database connection objects, no passing EventEmitter instances.
Worked Example: A Minimal Typed API Surface
Here's a practical pattern used in production Electron 30+ apps. The preload script exposes a single namespace window.app with typed methods for storage, IPC, and native dialogs. The main process validates every request.
preload.ts
import { contextBridge, ipcRenderer } from 'electron';
interface AppApi {
store: {
get(key: string): Promise<unknown>;
set(key: string, value: unknown): Promise<void>;
};
dialog: {
showOpenDialog(options: Electron.OpenDialogOptions): Promise<Electron.OpenDialogReturnValue>;
};
onMenuAction(callback: (action: string) => void): () => void;
}
const api: AppApi = {
store: {
get: (key) => ipcRenderer.invoke('store:get', key),
set: (key, value) => ipcRenderer.invoke('store:set', key, value),
},
dialog: {
showOpenDialog: (options) => ipcRenderer.invoke('dialog:open', options),
},
onMenuAction: (callback) => {
const listener = (_: Event, action: string) => callback(action);
ipcRenderer.on('menu:action', listener);
return () => ipcRenderer.off('menu:action', listener);
},
};
contextBridge.exposeInMainWorld('app', api);
renderer.ts (TypeScript declaration)
// renderer.d.ts
declare global {
interface Window {
app: {
store: {
get(key: string): Promise<unknown>;
set(key: string, value: unknown): Promise<void>;
};
dialog: {
showOpenDialog(options: Electron.OpenDialogOptions): Promise<Electron.OpenDialogReturnValue>;
};
onMenuAction(callback: (action: string) => void): () => void;
};
}
}
export {};
main.ts (IPC handlers with validation)
import { ipcMain, dialog } from 'electron';
import Store from 'electron-store';
const store = new Store();
ipcMain.handle('store:get', (_e, key: string) => {
if (typeof key !== 'string' || key.length > 128) throw new Error('Invalid key');
return store.get(key);
});
ipcMain.handle('store:set', (_e, key: string, value: unknown) => {
if (typeof key !== 'string' || key.length > 128) throw new Error('Invalid key');
// Add your own value validation here
store.set(key, value);
});
ipcMain.handle('dialog:open', async (_e, options: Electron.OpenDialogOptions) => {
// Sanitize options — never pass raw renderer input to native dialogs
const safeOptions: Electron.OpenDialogOptions = {
properties: ['openFile'],
filters: options.filters ?? [{ name: 'All Files', extensions: ['*'] }],
};
return dialog.showOpenDialog(safeOptions);
});
The renderer calls window.app.store.get('theme') or await window.app.dialog.showOpenDialog({...}). No require('electron'), no remote, no Node.js globals. The preload script is the only place privileged APIs live.
Trade-offs and Limitations You'll Hit
Structured clone is the hard constraint. You cannot pass a class instance with methods, a Promise chain, or a function that closes over private state. If you need streaming or progress callbacks, use ipcRenderer.on / ipcMain.emit with simple payloads, or consider UtilityProcess (Electron 17+) for background Node.js work that communicates via MessagePort — it bypasses contextBridge entirely for non-UI tasks.
Native modules add friction. Any native Node.js module (via node-gyp or N-API) must be rebuilt for Electron's specific ABI using @electron/rebuild. Version mismatches cause silent crashes in preload or main process. Pin your Electron version (e.g., 30.x LTS) and run rebuild in CI on every dependency change.
Sandbox defaults tighten over time. Electron 22+ enforces sandbox: true by default for all renderers. Combined with contextIsolation: true, even preload scripts lose direct access to some low-level Node.js APIs unless you configure permissions via session.setPermissionRequestHandler. Test upgrades in a staging environment before rolling to production.
Verify Your Setup Before Shipping
- Run
npx @electron/check@latestagainst your project — it catchesnodeIntegration,remote, and unsafe preload patterns. - Test structured-clone limits deliberately: try passing a class instance, a closure-capturing function, and a Promise over
contextBridge. Observe theTypeErroror data loss in the renderer console. - Add a native dependency (e.g.,
sharp), runnpm installthennpx @electron/rebuild, and confirm the app launches without "Module version mismatch" errors. - Pin to an LTS line (28.x, 30.x) and run the full test suite on each minor upgrade. Electron's ~4-month major cadence means defaults shift regularly.
If you're prototyping, the Electron Fiddle app lets you iterate on preload/renderer isolation patterns without a full project setup. But for anything shipping, the verification steps above are the minimum baseline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.