Electron Renderer Node.js: Using contextIsolation and contextBridge Securely
Enabling Node.js in Electron renderers the secure way, using contextIsolation, preload scripts, and contextBridge.
19 Apr 2026, 09:00 UTC

The problem with Node.js in Electron renderers
You are building an Electron app whose UI needs to read and write files. The tempting shortcut is nodeIntegration:true in the window's webPreferences, which puts the full Node.js API into the renderer. The catch: every script that runs in that window — including anything injected through a cross-site scripting (XSS) bug in your own bundle or in remote content — inherits those privileges. One XSS flaw becomes arbitrary file access on the user's machine. This is why Electron's documentation warns against enabling Node.js integration in any renderer that loads untrusted content.
The supported alternative keeps the renderer sandboxed, runs a small preload script with elevated privileges, and exposes exactly the functions your UI needs through contextBridge. Here is how that model works, plus a minimal setup for two common needs: showing the app version and opening a link in the user's browser.
How the isolation model works
Every BrowserWindow gets its own renderer process with a separate Chromium heap, and by default that process has no Node.js built-ins. The main process — the Node.js side of the app — keeps full privileges. A preload script runs before the page's own scripts and can use a limited set of Electron APIs. With contextIsolation enabled, the preload runs in its own JavaScript context: your web code and the preload both see a window object, but they are different objects, and only values explicitly passed through contextBridge cross the boundary.
Version assumptions matter here. contextIsolation defaults to true in Electron 12 and later, and renderers are sandboxed by default from Electron 20 onward. Sandboxing further restricts the preload: you cannot require arbitrary npm modules or Node built-ins like fs there — you get contextBridge, ipcRenderer, and a handful of others. Anything heavier, such as file access, belongs in the main process.
A minimal secure setup
Create the window with a preload, and register IPC handlers in the main process for the two capabilities the UI needs:
// main.js
const { app, BrowserWindow, ipcMain, shell } = require('electron')
const path = require('path')
function createWindow () {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, 'preload.js')
}
})
win.loadFile('index.html')
}
ipcMain.handle('app:get-version', () => app.getVersion())
ipcMain.handle('shell:open-external', (_event, url) => {
if (typeof url !== 'string' || !/^https?:\/\//.test(url)) {
throw new Error('Only http(s) URLs are allowed')
}
return shell.openExternal(url)
})
app.whenReady().then(createWindow)Then expose a narrow API from the preload. In a sandboxed preload, ipcRenderer.invoke is the way to reach main-process code:
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
getVersion: () => ipcRenderer.invoke('app:get-version'),
openExternal: (url) => ipcRenderer.invoke('shell:open-external', url)
})The renderer calls window.electronAPI.getVersion() and receives a promise. It never sees require, process, or ipcRenderer itself — only the two named functions. Note the validation in the open-external handler: because a compromised renderer could call any exposed channel, the main process should treat renderer input as untrusted and reject anything that is not an http(s) URL.
Verifying the bridge
- Run the app in development and open the renderer's DevTools console.
- Type window.electronAPI.getVersion(). A version string means the preload ran and the bridge is active.
- If you get undefined, check that the preload path resolved — path.join(__dirname, 'preload.js') is the reliable form — and watch the DevTools console for preload errors on startup.
- Also confirm that window.require is undefined in the renderer console; if it exists, Node integration is still enabled somewhere.
Trade-offs and limits
The bridge adds friction. Every new capability means another channel, another handler, and another input check in main, and data crossing IPC is serialized across a process boundary. Electron's IPC uses the structured clone algorithm, so plain objects, arrays, and typed arrays pass through, but functions and class instances with prototypes do not. For a small API surface this cost is trivial; for a renderer that needs heavy file access, do the work in the main process and pass back only results.
contextIsolation is also not a substitute for input validation. It stops renderer code from reaching Node.js directly, but anything you expose over IPC is a privileged call that a compromised renderer can still make. Keep the exposed surface small and validate arguments in the main process.
If your app needs native capabilities from its UI, start with contextIsolation on, a sandboxed preload, and one contextBridge exposure per feature. Verify in DevTools that the renderer sees only your named API, and push anything that touches the file system, network, or shell into the main process where you can validate it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.