Electron Security Hardening: Locking Down Your App with CSP, Sandboxing, and Least Privilege
TL;DR — An Electron app is a web page with a filesystem, a shell, and your user's credentials. The Electron security checklist from the official docs is a good start, but "good start" is not a threat model. This article walks through the four boundaries that actually matter — renderer compromise, IPC abuse, permission escalation, and navigation/link leaks — with runnable code for each fix and the mistakes that still show up in production apps.
Table of Contents
- Why Desktop Apps Are a Different Attack Surface
- The BrowserWindow Baseline: contextIsolation, Sandbox, and nodeIntegration
- Content Security Policy That Actually Works
- IPC as an Attack Surface: Channel Allowlists and Sender Validation
- Permission Requests: Least Privilege at the Session Level
- Navigation and Window Open: the Leakiest Defaults
- The Verification Checklist
1. Why Desktop Apps Are a Different Attack Surface
A web app's worst-case compromise is a stolen session cookie or some localStorage tokens. An Electron app's worst case is arbitrary code execution on the host: your renderer runs Chromium, and Chromium's renderer is a sandbox boundary — but only if you actually enable the sandbox. Most of the scary Electron CVEs of the last few years have one thing in common: the exploit started in the renderer, then walked out through an over-privileged bridge (IPC handler with shell access, a will-navigate to a file:// URL, or a CSP that allowed unsafe-eval plus a remote origin).
So the mental model is:
| Boundary | What it protects | What defeats it |
|---|---|---|
| Renderer sandbox | Chromium process isolation | sandbox: false, nodeIntegration: true |
| Context isolation | Your preload/IPC bridge | Missing contextIsolation (pre-Electron 12 default) |
| CSP | Code injection into your own UI | unsafe-inline, unsafe-eval, wildcard sources |
| Permission/session layer | Hardware + sensitive APIs | Blanket setPermissionRequestHandler approvals |
| Navigation control | Credential + file exfiltration | Unvalidated will-navigate, setWindowOpenHandler returns |
Treat each row as a boundary with a defined bypass. Your job is to make the bypass require a real vulnerability, not a default.
2. The BrowserWindow Baseline
Here is the baseline every window should start from. These are not optional hardening flags — they are the minimum viable configuration:
const { BrowserWindow, session } = require('electron'); const path = require('node:path'); function createSecureWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, // required: renderer cannot touch Node nodeIntegration: false, // required: no require() in the page sandbox: true, // required: renderer runs in a sandboxed process webviewTag: false, // disable unless you genuinely need <webview> spellcheck: false, // spellcheck hits the network with page content }, }); win.loadFile('index.html'); } Three details that bite people:
-
sandbox: truechanges your preload. A sandboxed preload runs in a restricted environment: you getrequireonly for a tiny allowlist (electron,events,timers,url). If your preload needsfsorpath, you must move that logic into the main process and expose it over IPC — which is exactly where it belongs anyway. -
contextIsolation: trueis not the same as security. It is a structural barrier: the page'swindowgets a separate context from the preload's. But if your preload doescontextBridge.exposeInMainWorld('api', { readFile: () => fs.readFileSync(...) }), the isolation is cosmetic. Everything your preload exposes is an attack surface. -
webviewTag: falsematters.<webview>is a deprecated but still-supported tag with its own guest process and its own IPC surface. If you do not use it, disable it. The same goes fornodeIntegrationInSubFrames— a flag almost nobody sets intentionally.
3. Content Security Policy That Actually Works
A CSP is the difference between "the XSS payload runs" and "the XSS payload is a console error." For Electron, the useful policy is strict: only your own origin, no inline, no eval.
<!-- index.html --> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" /> Why style-src 'unsafe-inline'? Because inline <style> and style= attributes are pervasive in real apps and are not a code-execution vector in Electron's renderer the way inline scripts are. Keep script-src strict and you cover the dangerous part.
The production-grade pattern for any non-trivial app is a nonce-based policy set from the main process:
const { session } = require('electron'); app.whenReady().then(() => { session.defaultSession.webRequest.onHeadersReceived((details, callback) => { const nonce = Buffer.from(crypto.randomBytes(16)).toString('base64'); // In a real app, generate the nonce when you build/serve the page and // inject it into both the HTML and the header. callback({ responseHeaders: { ...details.responseHeaders, 'Content-Security-Policy': [ `default-src 'self'; script-src 'self' 'nonce-${nonce}'; object-src 'none'; base-uri 'none'`, ], }, }); }); }); The traps I see in real code, in order of frequency:
| Trap | Why it hurts | Fix |
|---|---|---|
script-src 'self' 'unsafe-inline' 'unsafe-eval' | Neutralizes the policy | Never ship unsafe-eval; if React DevTools needs it, gate it to dev |
img-src * / connect-src * | Every image is an exfil channel | Scope to self + explicit CDN hosts |
| No CSP at all, "because our app is local" | Local XSS still runs with your privileges | Add the meta tag; it costs nothing |
| CSP only in dev, stripped for prod build | The policy you test is not the policy you ship | Generate the meta tag in your build script |
default-src * with one restriction | * wins; the restriction is decoration | Start from 'self' and add what breaks |
If you load remote content at all (webview, loadURL, iframe), treat that remote origin as hostile and give it a separate session with its own strict CSP — never the default session.
4. IPC as an Attack Surface
The most common architecture mistake is a single giant ipcMain.handle('do-thing', ...) that switches on a string. If your renderer is compromised, an attacker can call any handler with any payload. Three rules close most of it:
Rule 1 — a fixed channel allowlist, enforced structurally.
// main/ipc.js const ALLOWED = new Set([ 'settings:get', 'settings:set', 'data:export-csv', 'update:check', ]); ipcMain.handle('dispatch', async (event, { channel, payload }) => { if (!ALLOWED.has(channel)) { throw new Error(`Unknown channel: ${channel}`); } // handlers live in a map, not a switch return handlers[channel](event, payload); }); Rule 2 — validate the sender, not just the payload.
function assertTrustedSender(event) { const url = event.sender.getURL(); if (!url.startsWith('file://') && !url.startsWith('https://app.example.com')) { throw new Error(`Rejected IPC from untrusted frame: ${url}`); } } Why this matters: a compromised subframe (an ad iframe, a <webview>, a remote page you embed) can post messages that look like they come from your app. event.senderFrame + senderFrame.url is the only reliable way to know which frame actually sent the message. Check it on every handler that touches sensitive state.
Rule 3 — validate and shape every payload. ipcRenderer.invoke round-trips objects through structured clone. Do not trust payload.rows to be an array, payload.id to be a string, or payload.path to stay inside your app's data directory:
function assertSafePath(p) { const resolved = path.resolve(p); const root = path.resolve(app.getPath('userData')); if (!resolved.startsWith(root + path.sep)) { throw new Error(`Path escapes userData: ${resolved}`); } } The pattern that kills most apps in the wild is a "file-open" handler that takes a path from the renderer and calls shell.openPath(path) — an attacker who controls the renderer can point that at any executable on disk. Never pass paths from the renderer to shell; pass file descriptors of your own choosing (e.g., an id from your own recently-used list) and resolve them in the main process.
5. Permission Requests: Least Privilege at the Session Level
Chromium's permission model (navigator.mediaDevices.getUserMedia, notifications, geolocation, clipboard) defaults to a prompt. In Electron, the default is to allow unless you install a handler. That is backwards for a desktop app:
const { session } = require('electron'); const ALLOWED_PERMISSIONS = new Set(['clipboard-sanitized-write']); app.whenReady().then(() => { session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => { // Only trust the top frame of windows we created. if (!webContents.getURL().startsWith('file://')) { return callback(false); } if (ALLOWED_PERMISSIONS.has(permission)) { return callback(true); } // Everything else: deny by default. callback(false); }); session.defaultSession.setPermissionCheckHandler((webContents, permission) => { return ALLOWED_PERMISSIONS.has(permission); }); }); Notes:
-
setPermissionCheckHandleris the one that actually gatesnavigator.permissions.queryand the synchronous checks; forgetting it means your UI says "denied" while the API still works. -
media(camera/microphone) andgeolocationare almost never needed in a desktop app. Deny them globally; if a feature truly needs them, scope it to a dedicated window/session. - Notification permission is per-origin. If your app loads
file://, the origin is opaque — test notification behavior early, because "deny by default" can silently break a legit tray-notification feature.
6. Navigation and Window Open: the Leakiest Defaults
Two defaults deserve a hard look: what happens when a link is clicked, and what happens when the page tries to navigate itself away.
app.whenReady().then(() => { const { session } = require('electron'); session.defaultSession.setWindowOpenHandler(({ url }) => { // Never create a new Electron window from arbitrary links. if (url.startsWith('https://') || url.startsWith('http://')) { shell.openExternal(url); // hand it to the OS browser return { action: 'deny' }; } return { action: 'deny' }; }); }); For navigation:
win.webContents.on('will-navigate', (event, url) => { const allowed = new Set([win.webContents.getURL(), 'file://' + path.join(__dirname, 'index.html')]); if (!allowed.has(url)) { event.preventDefault(); shell.openExternal(url); } }); The specific leak I keep finding in production: an app that loads a remote dashboard URL and never checks will-navigate. An attacker who can inject one <a href="file:///C:/..."> or one location.href = 'file:///...' gets the renderer to navigate the privileged webContents to a local file, and any ipcRenderer listener attached to that webContents now runs in the context of the local file's origin. Check navigation, always — even if you believe your app never navigates.
7. The Verification Checklist
Before you ship, run through this list — and keep it in CI so it cannot rot:
- [ ]
contextIsolation: true,nodeIntegration: false,sandbox: trueon everyBrowserWindow - [ ]
webviewTag: falseand no<webview>in the DOM - [ ] CSP present in prod build with no
unsafe-eval; inline scripts use nonces - [ ] Preload exposes only typed, minimal API surface via
contextBridge - [ ] Every
ipcMainhandler validates sender frame origin and payload shape - [ ] No path from the renderer reaches
shellorfswithout main-process resolution + allowlist - [ ]
setPermissionRequestHandlerdenies by default; only sanctioned permissions allowed - [ ]
setWindowOpenHandlerdenies all and routes links toshell.openExternal - [ ]
will-navigatewhitelist enforced - [ ] Remote content (if any) runs in a separate, stricter session
- [ ]
ELECTRON_DISABLE_SECURITY_WARNINGSnever set in production - [ ]
app.enableSandbox()called (or equivalent) so the sandbox cannot be flipped off
Two final pieces of advice that cost nothing:
- Run the app with
--enable-loggingand read the security warnings Electron prints for every window that violates the baseline. They exist because the default was unsafe for a long time. - Test with the renderer "owned" by an attacker: assume a malicious script runs in your page and ask, what is the most valuable thing it can reach? If the answer is anything outside the renderer, close that path.
Security in Electron is not a feature you add — it is a set of defaults you refuse to inherit.
