Security
Electron's security checklist explained — threat model, contextIsolation, sandbox, CSP, navigation control, permission handlers, and secure defaults
Security
An Electron app ships a full browser and a Node.js runtime to every user's machine. The renderer runs untrusted-grade content (your HTML/JS, plus anything it loads — ads, embeds, dependencies), and the main process can touch the filesystem, spawn processes, and call OS APIs. Security in Electron is the discipline of keeping a compromised renderer from reaching that power.
The threat model
Assume any renderer can be compromised. A cross-site scripting bug, a malicious third-party ad or script, or a supply-chain attack in one of your npm dependencies can run arbitrary JavaScript in the renderer. That is normal for a web page — the browser sandbox contains it. The danger in Electron is when that JavaScript can reach require('child_process'), read the user's home directory, or invoke a privileged IPC handler that does so on its behalf.
The goal is defense in depth: even if attacker code runs in the renderer, every layer between it and the OS should be closed. No single setting makes you safe; the layers together do.
Attacker JS in renderer
│ blocked by → sandbox (no Node, OS-level process sandbox)
│ blocked by → contextIsolation (cannot reach preload internals)
│ blocked by → narrow contextBridge API (only the functions you exposed)
│ blocked by → IPC argument validation in main (untrusted input)
│ blocked by → CSP (cannot inject/eval new code)
▼
OS / filesystem / network ← should never be reachable directlySecure-by-default webPreferences
Modern Electron defaults are safe. The job is to not turn them off. This is the baseline for every BrowserWindow:
import { BrowserWindow } from 'electron'
import path from 'node:path'
const win = new BrowserWindow({
webPreferences: {
contextIsolation: true, // default true — keep it
sandbox: true, // turn it ON — strongest isolation
nodeIntegration: false, // default false — never enable for remote content
nodeIntegrationInWorker: false,
webSecurity: true, // default true — never disable
allowRunningInsecureContent: false,
preload: path.join(__dirname, 'preload.js'),
},
})| Option | Secure value | Why |
|---|---|---|
contextIsolation | true | Preload and page run in separate JS worlds; page cannot tamper with your bridge |
sandbox | true | Renderer runs in the OS process sandbox; preload gets only a polyfilled Node subset |
nodeIntegration | false | Page JS has no require, process, or Node globals |
nodeIntegrationInWorker | false | Same protection inside Web Workers |
webSecurity | true | Enforces same-origin policy and blocks file:// ↔ remote mixing |
allowRunningInsecureContent | false | No HTTP resources on an HTTPS page |
enableBlinkFeatures | unset | Don't opt into experimental, unaudited engine features |
The cardinal sins. Never set nodeIntegration: true, contextIsolation: false, sandbox: false, or webSecurity: false on a window that loads any content you do not 100% control. Any one of them, combined with a single XSS bug, hands the attacker the user's machine. The deprecated remote module is removed in modern Electron for the same reason — do not pull in @electron/remote to bring it back.
contextIsolation: true
Context isolation runs your preload script in a separate JavaScript context from the loaded web page. They share the same DOM, but they do not share JavaScript globals or prototypes. Without it, a script on the page could overwrite Array.prototype.map or reach into your preload's closures and steal the privileged objects you used.
With isolation on, the only thing the page can see from your preload is what you deliberately hand it through contextBridge. Disabling it collapses that wall and is treated by Electron's own checklist as a critical failure.
sandbox: true
The Chromium sandbox restricts the renderer process at the OS level: no direct filesystem access, no arbitrary syscalls, no spawning processes. With sandbox: true, your preload script also runs in a limited environment — it does not get full Node.js. It gets a polyfilled subset: electron (the renderer-side parts like ipcRenderer and contextBridge), plus events, timers, and url. There is no fs, no child_process, no require of arbitrary modules.
That constraint is the point. A sandboxed preload can only marshal data to the main process over IPC; all privileged work happens in main, where you can validate it. If you need Node APIs to build a feature, do that work in the main process and expose a narrow function — not a raw module — to the renderer.
Narrow contextBridge APIs
The bridge is where most real-world Electron vulnerabilities live. The rule: expose functions, never objects. Never hand the renderer ipcRenderer itself, a fs wrapper, or a generic "call any channel" method.
// preload.ts — GOOD: specific, intention-revealing functions
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('api', {
// Each function maps to ONE known, audited operation
loadProfile: (userId: string) => ipcRenderer.invoke('profile:load', userId),
saveNote: (note: { id: string; body: string }) =>
ipcRenderer.invoke('note:save', note),
onUpdateAvailable: (cb: () => void) => {
const handler = () => cb()
ipcRenderer.on('update:available', handler)
return () => ipcRenderer.removeListener('update:available', handler)
},
})// preload.ts — DANGEROUS: never do this
contextBridge.exposeInMainWorld('api', {
// Lets the page invoke ANY channel with ANY payload — total bypass
invoke: (channel: string, ...args: unknown[]) => ipcRenderer.invoke(channel, ...args),
// Hands raw Node power to the page
ipcRenderer,
readFile: (p: string) => require('fs').readFileSync(p),
})A generic invoke(channel, ...args) is exactly as dangerous as exposing Node, because it lets compromised page code reach every handler you registered, with payloads you never anticipated.
Treat all IPC input as untrusted
The main process is the security boundary. Every argument arriving over IPC came from the renderer and may be attacker-controlled. Validate types, ranges, and paths before acting — never interpolate IPC strings into a filesystem path or shell command.
// main.ts
import { ipcMain, app } from 'electron'
import path from 'node:path'
import { promises as fs } from 'node:fs'
const NOTES_DIR = path.join(app.getPath('userData'), 'notes')
ipcMain.handle('note:save', async (_event, note: unknown) => {
// 1. Validate shape (use zod/valibot in real code)
if (
typeof note !== 'object' || note === null ||
typeof (note as any).id !== 'string' ||
typeof (note as any).body !== 'string'
) {
throw new Error('Invalid note payload')
}
const { id, body } = note as { id: string; body: string }
// 2. Prevent path traversal — id must be a plain identifier
if (!/^[a-z0-9_-]{1,64}$/i.test(id)) throw new Error('Invalid id')
const target = path.join(NOTES_DIR, `${id}.json`)
// 3. Confirm the resolved path stays inside the allowed directory
if (!target.startsWith(NOTES_DIR + path.sep)) throw new Error('Path escape')
await fs.writeFile(target, JSON.stringify({ id, body }), 'utf8')
})You can also check event.senderFrame to ensure a message came from a trusted origin before honoring it.
Content-Security-Policy
A strong CSP is your last line of defense against XSS: even if an attacker can inject markup, CSP can prevent inline scripts and eval from executing. Set it via a response header (preferred — harder for injected markup to override) or a <meta> tag for local content.
// main.ts — set CSP on every response for your app's session
import { session } from 'electron'
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"connect-src 'self' https://api.example.com",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
].join('; '),
},
})
})Avoid unsafe-eval and unsafe-inline. They defeat the purpose of CSP. unsafe-eval re-enables eval() and new Function() — a primary XSS-to-RCE vector. unsafe-inline lets injected <script> and inline handlers run. If a build tool needs eval in development, scope that relaxation to dev only and ship a strict CSP in production.
Navigation and new-window control
A compromised or careless page can try to navigate itself to a malicious site (which then inherits your window's privileges) or open new windows. Lock both down.
// main.ts
import { shell } from 'electron'
app.on('web-contents-created', (_e, contents) => {
// 1. Block in-app navigation away from trusted origins
contents.on('will-navigate', (event, url) => {
const { origin } = new URL(url)
if (origin !== 'https://app.example.com' && !url.startsWith('file://')) {
event.preventDefault()
}
})
// 2. Deny window.open by default; route trusted links to the OS browser
contents.setWindowOpenHandler(({ url }) => {
const parsed = new URL(url)
if (parsed.protocol === 'https:') {
shell.openExternal(url) // opens in the user's real browser, not Electron
}
return { action: 'deny' }
})
})Opening external links in the system browser keeps untrusted pages out of your privileged windows entirely.
Validate shell.openExternal inputs
shell.openExternal hands a URL to the OS to open with the default handler. If the URL comes from untrusted content, an attacker can pass a file:// path or a dangerous custom scheme to trigger unwanted behavior. Always allow-list the scheme first.
function openExternalSafely(rawUrl: string): void {
let url: URL
try {
url = new URL(rawUrl)
} catch {
return // not a valid absolute URL — reject
}
if (url.protocol === 'https:' || url.protocol === 'mailto:') {
shell.openExternal(url.href)
}
// everything else (file:, javascript:, smb:, custom schemes) is dropped
}Permission handling
Web content can request camera, microphone, geolocation, notifications, and more. Electron grants nothing by default in many cases, but you should explicitly control it so a compromised renderer cannot silently obtain sensitive capabilities.
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
const allowed = new Set(['notifications', 'clipboard-read'])
callback(allowed.has(permission))
})
// Synchronous check for permissions queried without a prompt
session.defaultSession.setPermissionCheckHandler((_wc, permission) => {
return permission === 'notifications'
})Only load local content or HTTPS
Load your app from packaged local files (file:// or a custom protocol you register) or from HTTPS — never plain HTTP, which is trivially man-in-the-middled. Do not mix a file:// page with remote content; that combination, with webSecurity enabled, is blocked for good reason. If you serve a dev server, use it only in development.
Loading remote content safely
If you genuinely must embed remote, untrusted content, isolate it. Use a separate session/partition, give that window no preload, no Node, sandbox on, and a strict CSP. It should be able to do nothing but render.
const untrusted = new BrowserWindow({
webPreferences: {
partition: 'persist:untrusted', // separate cookie/storage jar
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
// no preload — this window has zero bridge to your privileged code
},
})Keep Electron updated
Electron bundles Chromium and Node. Security fixes in those upstream projects only reach your users when you bump Electron. A stale Electron version ships known, public Chromium vulnerabilities. Pin the version for reproducible builds, but bump it on a regular cadence and track Electron's security releases. Combine this with auto-update so fixes actually land on user machines.
Secrets
The renderer is fully inspectable — anything bundled into it (API keys, tokens) is readable by anyone who opens DevTools or unpacks the asar. Do not hardcode secrets. For per-user credentials, use the OS keychain via Electron's safeStorage (encrypts with an OS-provided key) or a library like keytar.
import { safeStorage } from 'electron'
if (safeStorage.isEncryptionAvailable()) {
const blob = safeStorage.encryptString(token) // store this blob on disk
const token = safeStorage.decryptString(blob) // decrypt at runtime
}Disable dangerous toggles at build time with fuses
@electron/fuses flips low-level binary flags when you package the app, so they cannot be re-enabled at runtime. Turn off capabilities you don't use — most importantly RunAsNode, which otherwise lets the Electron binary be invoked as a plain Node interpreter (a known privilege-escalation and bypass vector).
import { flipFuses, FuseV1Options, FuseVersion } from '@electron/fuses'
await flipFuses(electronBinaryPath, {
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
})Run the checklist as a gate, not a one-time audit. Electron ships the security warnings that fire in the DevTools console during development — do not ignore them. Bake the secure webPreferences, CSP header, navigation handlers, and fuse configuration into your project template so every new window and every new build inherits them by default rather than relying on a reviewer to remember.