Electron Renderer Trust Boundary: contextIsolation and Preload IPC
Isolate Electron renderer processes with contextIsolation, nodeIntegration, and a typed preload API; validate all IPC in the main process.
03 Nov 2025, 08:10 UTC

The problem: renderer compromise is host compromise
In an Electron application, the renderer process runs the application's HTML, CSS, and JavaScript. If that process can access Node.js modules or Electron internals directly, a single cross-site scripting bug becomes arbitrary code execution on the user's computer. The renderer should be treated as untrusted, while the main process remains the only component allowed to perform privileged operations such as reading files, launching programs, or opening native dialogs.
Useful takeaway: enable contextIsolation, disable nodeIntegration, and expose a small, typed API from a preload script through contextBridge. Validate every IPC request in the main process before acting on it.
Requirements
- The renderer must not be able to load or execute arbitrary Node.js code.
- The UI may need limited access to operating-system features, such as reading a user configuration file.
- The main process must authorize and validate every request that crosses the process boundary.
Smallest suitable design
Configure the BrowserWindow so the renderer runs without Node integration and loads a preload script. The preload script is the trust boundary: it can use Node and Electron APIs, but it exposes only chosen functions to the renderer.
const { BrowserWindow } = require('electron')
const path = require('path')
function createWindow () {
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
win.loadFile('index.html')
}
contextIsolation separates the preload script's JavaScript context from the renderer's context. nodeIntegration prevents the renderer from using Node.js modules directly. Verify these defaults for your installed Electron version; they changed in Electron 12 and may differ in your release.
Preload script
The preload script exposes one narrow API surface. In this example, the renderer can request configuration reads, but nothing else.
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
readConfig: (configPath) => ipcRenderer.invoke('config:read', configPath)
})
The renderer can now call window.electronAPI.readConfig(path). It cannot access window.require, window.electron, or ipcRenderer directly.
Main process handler
Implement the corresponding handler in the main process. Validate every argument before performing any privileged operation.
const { ipcMain } = require('electron')
const fs = require('fs')
const path = require('path')
const ALLOWED_CONFIG_PATHS = [
path.join(__dirname, 'config', 'app.json')
]
ipcMain.handle('config:read', async (event, configPath) => {
if (typeof configPath !== 'string' || !ALLOWED_CONFIG_PATHS.includes(configPath)) {
throw new Error('Unauthorized config path')
}
return fs.promises.readFile(configPath, 'utf8')
})
Do not expose require, electron, child_process, or shell through contextBridge. Doing so lets the renderer escape the sandbox. The deprecated remote module should not be used; it weakens the main/renderer boundary and is removed in current Electron versions.
Trust and data boundaries
| Layer | Trust level | Responsibility |
|---|---|---|
| Renderer world | Untrusted | Renders UI; handles user input only |
| Preload script | Isolated bridge | Exposes typed API; no direct renderer access to Node |
| IPC channel | Untrusted data | Transfers serialized arguments; main process validates all input |
| Main process | Trusted | Performs file, network, and shell operations after authorization |
Operational checks
Where to run: application root directory. Permissions: normal file-system access to edit source. Placeholders: replace with your project path.
1. Confirm window preferences
grep -R -e contextIsolation -e nodeIntegration -e preload /src/main.js
Expected check: contextIsolation is true, nodeIntegration is false, and preload points to an existing file. Risk: configuration may be split across files or generated dynamically, so a missing match is not conclusive.
2. Confirm renderer isolation
Open the renderer's DevTools console and evaluate:
console.log(window.require, window.electron)
console.log(typeof window.electronAPI)
Expected check: window.require and window.electron are undefined. Only names exposed by contextBridge.exposeInMainWorld, such as window.electronAPI, should exist. Risk: this requires developer-tools access to the running renderer.
3. Confirm IPC rejection
Call the handler from the renderer with an invalid path:
window.electronAPI.readConfig('/etc/passwd').then(
(result) => console.log('unexpected success', result),
(error) => console.log('expected rejection', error.message)
)
Expected check: the promise rejects with an error such as 'Unauthorized config path' before any file operation occurs. Risk: do not run this against production data paths.
Failure modes
- Preload script throws during startup: the exposed API is unavailable, and renderer calls fail. Check the main-process console for preload load errors.
- Malicious renderer sends invalid IPC payloads: the main-process handler must reject them. A missing validation check can lead to file disclosure or command execution.
- Main-process handler crashes: an unhandled exception in an
ipcMain.handlehandler can affect every renderer using that channel. Wrap handler logic and return structured errors instead of throwing where possible.
Conditions that change the design
- Legacy renderer code requires direct
requireaccess. You may temporarily relax isolation, but treat this as a documented risk and plan to move the code to the main process or a preload wrapper. - A trusted internal tool accepts higher risk for developer ergonomics. Even then, avoid exposing
shellorchild_processglobally. - The UI must spawn child processes. Redesign so the main process owns spawning under a strict allowlist, rather than exposing process APIs to the renderer.
Rollback
Changing webPreferences alters application behavior. If enabling contextIsolation breaks the preload script and the application will not start, temporarily revert to the previous values while you fix the preload:
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: false,
nodeIntegration: true
}
This rollback restores operation but weakens the renderer boundary. Do not leave it in place longer than necessary.
Limitations
contextIsolationdoes not prevent cross-site scripting from injecting malicious DOM content.- It does not secure vulnerabilities inside the main process itself.
- Preload script errors remove the exposed API from the renderer.
- Validation bugs in
ipcMainhandlers remain exploitable.
Practical way to verify the result
- Launch the application with developer tools open.
- In the renderer console, confirm
window.requireandwindow.electronareundefined. - Confirm the names exposed through
contextBridgeexist and are functions. - Call an IPC handler with an invalid payload and confirm it is rejected before any privileged operation runs.
- Review the installed Electron release notes for changes to default security preferences and
contextBridgebehavior.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.