Secure Renderer-to-Main IPC in Electron Using contextBridge
Expose a limited API from Electron's preload script to the renderer with contextBridge, keeping Node integration disabled.
10 Jul 2025, 02:27 UTC

The problem: your renderer shouldn't touch Node directly
Your Electron renderer needs to do something only the main process can do—write a file, open a native dialog, or read a config value. The tempting shortcut is to enable nodeIntegration in the renderer so your web code can call Node directly. That shortcut removes the sandbox boundary between your web content and the operating system, which is exactly what Electron's security model exists to prevent.
The renderer process is the Chromium window that runs your UI. The main process is the Node.js entry point that creates windows and owns privileged operations. A preload script runs before the renderer loads and can act as a controlled bridge. Context isolation gives the preload script its own JavaScript context, separate from the renderer's main world, so the renderer cannot reach into Electron or Node modules directly.
The practical takeaway: keep nodeIntegration: false, enable contextIsolation: true, and expose only the functions you need through contextBridge.exposeInMainWorld. The renderer calls those functions asynchronously through ipcRenderer.invoke, and the main process validates every request.
Configure the window and preload script
In your main process file (commonly main.js), create the BrowserWindow with strict webPreferences:
const { BrowserWindow } = require('electron')
const path = require('node:path')
function createWindow () {
const win = new BrowserWindow({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, 'preload.js')
}
})
win.loadFile('index.html')
}
Run this from your project root; no special permissions are required. Setting nodeIntegration: false means the renderer's window object will not expose require, process, or other Node globals.
In preload.js, expose a narrow API:
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
saveNote: (note) => ipcRenderer.invoke('save-note', note)
})
Because context isolation is on, this code runs in the isolated preload context. The renderer receives only the saveNote function; it cannot access ipcRenderer itself.
Handle the request in the main process
In main.js, register a handler for the same channel:
const { ipcMain } = require('electron')
const fs = require('node:fs')
ipcMain.handle('save-note', async (event, note) => {
if (!note || typeof note.text !== 'string') {
throw new Error('Invalid note payload')
}
const safeText = note.text.slice(0, 10000)
await fs.promises.writeFile('/path/to/notes.txt', safeText)
return { ok: true }
})
Start your Electron app from the project root with electron . or your configured start script. Required permissions are none beyond normal file-system access for the paths your main process touches. Expected checks: the main process receives the note, validates it, writes the file, and returns { ok: true }. Risks: writing to a hard-coded path is only an example; in production, validate the destination path and never let the renderer choose it directly.
In your renderer code, call the exposed function:
document.getElementById('save').addEventListener('click', async () => {
try {
const result = await window.api.saveNote({ text: 'Hello from the renderer' })
console.log('Saved:', result)
} catch (err) {
console.error('Save failed:', err)
}
})
Verify the boundary
Open Chrome DevTools in your renderer window and run these checks in the Console:
typeof window.api.saveNoteshould log'function'.typeof requireandtypeof processshould log'undefined', confirming Node integration is disabled.window.api.ipcRenderershould beundefined, confirming the renderer did not receive the raw IPC module.
These are checks you should perform yourself after starting the app. If window.api is undefined, confirm your preload path is correct and that contextIsolation is enabled.
Limitations and trade-offs
The exposed API is client-facing code. A renderer can call saveNote with malformed, oversized, or malicious data, so every argument must be validated inside the main-process handler. Do not expose secrets, file-system paths, or Electron objects through the bridge. Keep the API surface minimal and purpose-specific.
Avoid ipcRenderer.sendSync in the preload script. Synchronous IPC blocks the renderer's UI thread and can freeze the window while the main process works. Use ipcRenderer.invoke and async handlers instead.
This pattern adds a small amount of indirection compared with enabling nodeIntegration, but that indirection is the security boundary. You cannot remove the preload script without giving the renderer direct Node access.
Actionable closing
Before shipping, audit your preload script and ask: does each exposed function map to a single, well-defined main-process task? Are all arguments validated in ipcMain.handle? Are you returning only the data the renderer actually needs? If the answer to any of those is no, narrow the API and add validation before enabling context isolation in production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.