Electron lets a small team ship one app to Windows, macOS and Linux using the web skills they already have. The same power is also the risk: a renderer that can reach Node.js or the operating system turns an ordinary web bug into code running on your customer’s machine.
Electron publishes an official security checklist. This post is how we apply it in practice: the twelve checks we run on an Electron codebase before a release, in the order we review them, with code you can copy.
The 12 checks at a glance
- Stay on a supported Electron version
- Keep the secure defaults switched on
- Expose a small, named API through the preload script
- Validate every IPC message in the main process
- Load your own files through a custom protocol
- Block unexpected navigation and new windows
- Treat shell.openExternal as dangerous
- Add a Content Security Policy
- Deny permission requests by default
- Turn off unused features with fuses
- Sign the app and keep update checks strict
- Audit dependencies and isolate remote content
1. Stay on a supported Electron version
Electron ships a new major version about every eight weeks and supports only the latest three. Every release bundles a newer Chromium with its security fixes, so an app pinned to an old Electron keeps known browser holes open.
- Check the version in
package.jsonagainst the Electron release timeline. - Let Dependabot or Renovate open the upgrade pull requests, so updates are routine instead of a once-a-year project.
- Run the app’s smoke tests on every major upgrade; breaking changes are listed in each release’s notes.
2. Keep the secure defaults switched on
Modern Electron already ships with Node.js disabled in renderers, context isolation on and the renderer sandbox on. We still write them out explicitly. A reviewer can then see the intent at a glance, and nobody can quietly flip one of them while debugging.
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
});
If a codebase sets any of these to the unsafe value, that is the first thing we fix. There is almost always a cleaner way to get the feature that someone switched it off for.
3. Expose a small, named API through the preload script
The preload script is the only bridge between your web code and the main process. Expose one function per action the UI really needs. Never expose ipcRenderer itself, because that hands every page script the whole IPC surface.
// preload.js
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("api", {
openFile: () => ipcRenderer.invoke("dialog:openFile"),
saveSettings: (settings) => ipcRenderer.invoke("settings:save", settings),
});
4. Validate every IPC message in the main process
Treat each IPC call like a request to a public API. Check who sent it and check the shape of the data before acting on it. The main process has full system access, so this is where validation matters most.
ipcMain.handle("settings:save", (event, settings) => {
if (!event.senderFrame || !isAppUrl(event.senderFrame.url)) {
throw new Error("IPC call from an unexpected page");
}
if (typeof settings?.theme !== "string") {
throw new Error("Invalid settings payload");
}
return saveSettings({ theme: settings.theme });
});
5. Load your own files through a custom protocol
Loading the UI with file:// gives every page read access to the local file system through the browser. A privileged custom protocol limits the app to its own bundle and gives it a proper origin for the Content Security Policy in check 8.
const { app, net, protocol } = require("electron");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const DIST = path.join(__dirname, "dist");
protocol.registerSchemesAsPrivileged([
{
scheme: "app",
privileges: { standard: true, secure: true, supportFetchAPI: true },
},
]);
function isAppUrl(raw) {
try {
const url = new URL(raw);
return url.protocol === "app:" && url.host === "bundle";
} catch {
return false;
}
}
app.whenReady().then(() => {
protocol.handle("app", (request) => {
const { pathname } = new URL(request.url);
const relative = path.normalize(decodeURIComponent(pathname));
const filePath = path.join(DIST, relative);
if (!filePath.startsWith(DIST + path.sep)) {
return new Response("Not found", { status: 404 });
}
return net.fetch(pathToFileURL(filePath).toString());
});
// Then load the UI with: win.loadURL("app://bundle/index.html");
});
6. Block unexpected navigation and new windows
If an attacker can make your window navigate to their page, that page inherits everything the window can do. Allow navigation only inside your own bundle, and open every other link in the user’s normal browser.
app.on("web-contents-created", (_event, contents) => {
contents.on("will-navigate", (event, url) => {
if (!isAppUrl(url)) event.preventDefault();
});
contents.setWindowOpenHandler(({ url }) => {
if (isSafeExternalUrl(url)) shell.openExternal(url);
return { action: "deny" };
});
});
7. Treat shell.openExternal as dangerous
shell.openExternal asks the operating system to open a URL with whatever program handles it. With an untrusted URL, that can be a local executable or an unusual protocol handler. Allow only https: links to hosts you expect.
const ALLOWED_HOSTS = new Set(["clouwood.com", "github.com"]);
function isSafeExternalUrl(raw) {
try {
const url = new URL(raw);
return url.protocol === "https:" && ALLOWED_HOSTS.has(url.hostname);
} catch {
return false;
}
}
8. Add a Content Security Policy
A Content Security Policy tells Chromium which scripts, styles and servers your UI may use. If someone does inject markup, the browser refuses to run scripts that you did not ship. Start strict and open only what the app needs, for example by adding your API server to connect-src.
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; img-src 'self' data:"
/>
Some UI libraries need style-src 'unsafe-inline'. Add that only if they do, and never add 'unsafe-eval' to script-src to work around a library.
9. Deny permission requests by default
Web pages can ask for the camera, microphone, location, notifications and more. In a desktop app there is no browser prompt in between unless you add one, so decide in code which permissions your app really uses and refuse the rest.
const ALLOWED_PERMISSIONS = new Set(["clipboard-sanitized-write"]);
session.defaultSession.setPermissionRequestHandler(
(_contents, permission, callback) => {
callback(ALLOWED_PERMISSIONS.has(permission));
},
);
Pair it with setPermissionCheckHandler so synchronous permission checks give the same answer.
10. Turn off unused features with fuses
Fuses are switches baked into the Electron binary at package time. Malware cannot simply flip them back with an environment variable or a command-line flag. The ones below remove ways of running your signed app as a general-purpose Node.js runtime and stop the app from loading code from outside its archive.
const { flipFuses, FuseVersion, FuseV1Options } = require("@electron/fuses");
await flipFuses(pathToPackagedElectronBinary, {
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
});
Electron Forge has a Fuses plugin that does this for you. With electron-builder, call flipFuses from an afterPack hook. The fuses documentation lists every option and which platforms support it.
11. Sign the app and keep update checks strict
An unsigned app trains users to click through security warnings, and an update channel without checks is a direct path onto every installed machine.
- macOS: sign with a Developer ID certificate and notarize every build with Apple, or Gatekeeper will block it.
- Windows: sign the installer and the app executable, so SmartScreen and antivirus tools can tie the files to your company.
- Updates: serve them over HTTPS only, from a host you control, and leave the updater’s signature checks switched on.
12. Audit dependencies and isolate remote content
Most of an Electron app is third-party JavaScript. Run npm audit --omit=dev in CI, review lockfile changes in pull requests, and remove packages the app no longer uses.
If the app must show remote web content, such as a help page or a login screen, load it in a separate window with no preload script and no IPC handlers. Remote code should never share a window with your privileged UI.
How we use this list
We run this review on every Electron project before a release, and on existing apps that a client brings to us. The fixes are rarely large: most issues are one unsafe setting, an over-broad preload API or a missing navigation guard.
Want a second pair of eyes on your Electron app?
We will review your app’s security setup and send you a short report of what to fix first.






