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

  1. Why Desktop Apps Are a Different Attack Surface
  2. The BrowserWindow Baseline: contextIsolation, Sandbox, and nodeIntegration
  3. Content Security Policy That Actually Works
  4. IPC as an Attack Surface: Channel Allowlists and Sender Validation
  5. Permission Requests: Least Privilege at the Session Level
  6. Navigation and Window Open: the Leakiest Defaults
  7. 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:

  1. sandbox: true changes your preload. A sandboxed preload runs in a restricted environment: you get require only for a tiny allowlist (electron, events, timers, url). If your preload needs fs or path, you must move that logic into the main process and expose it over IPC — which is exactly where it belongs anyway.
  2. contextIsolation: true is not the same as security. It is a structural barrier: the page's window gets a separate context from the preload's. But if your preload does contextBridge.exposeInMainWorld('api', { readFile: () => fs.readFileSync(...) }), the isolation is cosmetic. Everything your preload exposes is an attack surface.
  3. webviewTag: false matters. <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 for nodeIntegrationInSubFrames — 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:

  • setPermissionCheckHandler is the one that actually gates navigator.permissions.query and the synchronous checks; forgetting it means your UI says "denied" while the API still works.
  • media (camera/microphone) and geolocation are 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: true on every BrowserWindow
  • [ ] webviewTag: false and 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 ipcMain handler validates sender frame origin and payload shape
  • [ ] No path from the renderer reaches shell or fs without main-process resolution + allowlist
  • [ ] setPermissionRequestHandler denies by default; only sanctioned permissions allowed
  • [ ] setWindowOpenHandler denies all and routes links to shell.openExternal
  • [ ] will-navigate whitelist enforced
  • [ ] Remote content (if any) runs in a separate, stricter session
  • [ ] ELECTRON_DISABLE_SECURITY_WARNINGS never set in production
  • [ ] app.enableSandbox() called (or equivalent) so the sandbox cannot be flipped off

Two final pieces of advice that cost nothing:

  1. Run the app with --enable-logging and read the security warnings Electron prints for every window that violates the baseline. They exist because the default was unsafe for a long time.
  2. 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.