Use when building, hardening, or shipping a cross-platform Electron desktop app — main/renderer/preload process model, typed contextBridge IPC, locking down nodeIntegration/contextIsolation/sandbox/CSP, or packaging with signing and auto-update. NOT a Rust-backed shell on the native webview (that is tauri), nor the web UI inside it (that is react).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill electron --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Electron?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-electron)More formats (shields.io, HTML) on the badges page.
---
name: electron
description: "Use when building, hardening, or shipping a cross-platform Electron desktop app — main/renderer/preload process model, typed contextBridge IPC, locking down nodeIntegration/contextIsolation/sandbox/CSP, or packaging with signing and auto-update. NOT a Rust-backed shell on the native webview (that is tauri), nor the web UI inside it (that is react)."
tags: [electron, desktop, ipc, security, packaging, auto-update, code-signing]
recommends: [tauri, react, nodejs, github-actions, secure-coding]
origin: risco
---
# Electron — desktop shell, typed IPC, hardening, signing
This skill owns the **desktop shell**: process model, IPC, security, packaging, signing,
auto-update. It does **not** own the web UI inside the window (`../react/SKILL.md`), the
Node backend logic, or the CI runner matrix.
## The mental model — three processes, one rule
An Electron app is three kinds of process. Code lives in exactly one; putting it in the
wrong one is the root cause of most security holes.
| Process | Runtime | Trust | One per | Does |
|----------|----------------------|---------------|---------|---------------------------------------------------|
| main | Node.js, full OS API | trusted | app | windows, menus, tray, dialogs, fs, child procs |
| renderer | Chromium, no Node | **untrusted** | window | your web UI; can run attacker JS if you load remote content |
| preload | isolated world, runs before page JS | semi-trusted | window | the **only** bridge: `contextBridge` exposes a tiny API |
**The governing rule: the renderer is untrusted, the main process holds all privilege, and
the preload is the only sanctioned bridge between them.** Renderer-to-OS escalation is the
dominant failure mode in real Electron apps, so everything below is a corollary.
## Start right
Scaffold with **Electron Forge** (`@electron/forge`) — first-party, all-in-one
(scaffold → package → make → publish), and it gets new Electron features first.
```bash
npm init electron-app@latest my-app -- --template=vite-typescript
cd my-app && npm start
```
**Pin to a supported major.** Electron ships a new major every 8 weeks (tracking Chromium)
and supports only the **latest 3 majors**. As of June 2026 the stable line is **Electron 42**
(Chromium M148, Node 24); 43 lands 2026-06-30. Shipping on an EOL major means unpatched
Chromium CVEs — check `package.json` and bump if behind.
Project layout keeps the boundary visible:
```text
src/
main.ts # main process — owns everything privileged
preload.ts # the bridge — contextBridge only
renderer/ # your web UI (untrusted)
ipc/types.ts # IPC contract shared by main + preload
```
## The security baseline
Modern Electron defaults are already secure (`nodeIntegration:false`,
`contextIsolation:true`, `sandbox:true` since Electron 20). **Assert them explicitly anyway**
so a careless edit can't silently weaken the window:
```ts
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false, // renderer gets NO require/process — never flip true
contextIsolation: true, // preload + page run in separate JS worlds
sandbox: true, // renderer in an OS sandbox; preload uses a limited API
webSecurity: true, // keep same-origin policy; never disable to "fix CORS"
allowRunningInsecureContent: false, // no mixed http content on https pages
},
});
```
One why per flag: each removes a documented way for renderer-side script to reach Node or
the OS. Flipping any of them to the insecure value is what `verify.sh` fails on.
**CSP via response headers, not a `<meta>` tag** — meta CSP can't restrict the initial
document and is trivially bypassed for some directives. Set it in the main process:
```ts
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
cb({ responseHeaders: { ...details.responseHeaders,
'Content-Security-Policy': ["default-src 'self'; script-src 'self'"] } });
});
```
**Lock navigation.** A renderer that can navigate to attacker content gets the renderer's
privileges. Deny unexpected navigation and block new windows:
```ts
app.on('web-contents-created', (_e, contents) => {
contents.on('will-navigate', (e, url) => {
if (new URL(url).origin !== 'https://app.local') e.preventDefault();
});
contents.setWindowOpenHandler(() => ({ action: 'deny' })); // no tab-jacking
});
```
Open real external links deliberately, after allow-listing the protocol:
```ts
function openExternal(url: string) {
const { protocol } = new URL(url);
if (protocol === 'https:' || protocol === 'mailto:') shell.openExternal(url);
}
```
**Which branch are you on?**
- **Local-only UI** (you bundle the HTML/JS): CSP + `sandbox:true` + nav lockdown is enough.
- **Loads any remote/third-party content**: also add Electron Fuses (disable run-as-node,
encrypt cookies, ASAR integrity) and treat every embedded origin as hostile.
Fuses and the full hardened example live in `references/security-and-ipc.md`.
## Typed IPC the right way
IPC is the seam between untrusted renderer and privileged main. Get it wrong and you've
handed the OS to whatever script runs in the page.
**Never expose `ipcRenderer` (or any of its methods) across the bridge.** Sending the whole
module now yields an *empty object* on the other side — a deliberate footgun removal — and
exposing its methods lets the page call any channel with any payload.
```ts
// Bad — preload.ts: hands the renderer a universal IPC weapon (also: empty object now)
contextBridge.exposeInMainWorld('api', ipcRenderer);
```
```ts
// Good — preload.ts: ONE function per channel, each wrapping a specific call
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('api', {
readConfig: () => ipcRenderer.invoke('config:read'),
saveNote: (text: string) => ipcRenderer.invoke('note:save', text),
onSync: (cb: () => void) => ipcRenderer.on('sync:done', cb), // events: send/on
});
```
**Prefer `ipcMain.handle` + `ipcRenderer.invoke`** (request/response, returns a Promise) for
anything that returns data. Reserve `send`/`on` for fire-and-forget events (progress, push
notifications). **Validate every argument in main** — a renderer message is an HTTP request
from an untrusted client:
```ts
ipcMain.handle('note:save', (_e, text: unknown) => {
if (typeof text !== 'string' || text.length > 10_000) throw new Error('bad input');
return saveNote(text); // never path.join(userInput) or eval it
});
```
Share the contract as TypeScript types across both sides (`ipc/types.ts`) so a channel
rename breaks the build, not production. Full main + preload + `window.api` d.ts example:
`references/security-and-ipc.md`.
## Native capabilities — renderer asks, main acts
The renderer can't (and must not) touch the OS directly. When the UI needs a native menu,
tray icon, file dialog, system notification, custom `protocol://` handler, or a
`child_process`, the renderer **invokes an IPC channel** and the **main process performs the
action** and returns a result. Same one-function-per-channel discipline as above.
For embedding web content in a region of a window, use **`WebContentsView`** —
`BrowserView` is deprecated since Electron 30. They share shape (both take `webPreferences`;
`setBounds`/`getBounds`/`webContents` carry over), so migration is mechanical.
## Packaging, signing, auto-update
Two real toolchains:
| Need | Use |
|-------------------------------------------------|------------------|
| New app, first-party alignment, features first | **Electron Forge** (ASAR integrity, universal macOS, scaffold→make→publish) |
| Differential/staged updates, multi-provider (GitHub/S3), richer config | **electron-builder** + `electron-updater` |
**Code signing is a prerequisite for auto-update, not optional polish.** macOS auto-update
(Squirrel.Mac) **refuses** to update an app that isn't signed *and* notarized; Windows
updates need an Authenticode-signed installer. So the order is always: sign → notarize →
publish → auto-update. Full Forge and builder configs, notarytool steps, Windows
Authenticode, and `electron-updater` + GitHub Releases wiring: `references/packaging-and-updates.md`.
The CI matrix that *runs* these builds across three OSes is `github-actions`' job; this skill
defines *what* to build and sign.
## Anti-patterns and migration smells
| Anti-pattern | Why it's wrong | Do instead |
|-------------------------------------------------------|------------------------------------------------------------|-------------------------------------------------------|
| `nodeIntegration: true` | Page JS gets `require('fs')`; any XSS becomes OS-level RCE | `false`; move the capability behind IPC |
| `contextIsolation: false` | Page can rewrite the preload's globals | `true` (the default) |
| `sandbox: false` without a reason | Drops the OS sandbox around the renderer | `true`; only relax for a measured, isolated need |
| `exposeInMainWorld('api', ipcRenderer)` or its methods | Universal IPC weapon — any channel, any payload (and an empty object now) | One typed function per channel |
| No arg validation in `ipcMain.handle` | Renderer is an untrusted client; you trust its input | Type-check + bound every arg before acting |
| `webSecurity: false` to "fix CORS" | Disables same-origin policy app-wide | Keep `true`; proxy/handle CORS in main |
| CSP only in a `<meta>` tag | Doesn't cover the initial document; bypassable | Set CSP in `onHeadersReceived` |
| Loading a remote URL into a Node-enabled window | Remote site runs with your app's privilege | Bundle UI locally; sandbox + nav lockdown for remote |
| `@electron/remote` import | Sync main-object access = renderer→main RCE | Replace with explicit `ipcMain.handle` channels |
| `new BrowserView(...)` | Deprecated since Electron 30, will be removed | `new WebContentsView(...)` |
| Auto-update with an unsigned/un-notarized build | Squirrel.Mac silently refuses; no updates ship | Sign + notarize (mac), Authenticode (win) first |
| Shipping on an EOL Electron major | Unpatched Chromium CVEs in your users' hands | Stay within the latest 3 majors |
| Heavy CPU work in the main process | Blocks the event loop → the whole UI freezes | `utilityProcess`/worker, or do it in the renderer |
## Verify
Run `scripts/verify.sh /path/to/your-electron-project` to grep a target for insecure
patterns (`nodeIntegration: true`, `contextIsolation: false`, `sandbox: false`,
`@electron/remote`, `new BrowserView`, `exposeInMainWorld(..., ipcRenderer)`). It's read-only
and exits non-zero on any finding. With no argument it self-checks this skill's own example
snippets for the secure baseline. See `references/security-and-ipc.md` for the full checklist.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!