Electron — desktop shell, typed IPC, hardening, signing

SkillAI & models

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).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Electron — desktop shell, typed IPC, hardening, signing skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/electron/SKILL.md and read by ahel’s review.

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.

ProcessRuntimeTrustOne perDoes
mainNode.js, full OS APItrustedappwindows, menus, tray, dialogs, fs, child procs
rendererChromium, no Nodeuntrustedwindowyour web UI; can run attacker JS if you load remote content
preloadisolated world, runs before page JSsemi-trustedwindowthe 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.

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:

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:

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:

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:

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:

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.

// Bad — preload.ts: hands the renderer a universal IPC weapon (also: empty object now)
contextBridge.exposeInMainWorld('api', ipcRenderer);
// 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:

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 WebContentsViewBrowserView 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:

NeedUse
New app, first-party alignment, features firstElectron Forge (ASAR integrity, universal macOS, scaffold→make→publish)
Differential/staged updates, multi-provider (GitHub/S3), richer configelectron-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-patternWhy it's wrongDo instead
nodeIntegration: truePage JS gets require('fs'); any XSS becomes OS-level RCEfalse; move the capability behind IPC
contextIsolation: falsePage can rewrite the preload's globalstrue (the default)
sandbox: false without a reasonDrops the OS sandbox around the renderertrue; only relax for a measured, isolated need
exposeInMainWorld('api', ipcRenderer) or its methodsUniversal IPC weapon — any channel, any payload (and an empty object now)One typed function per channel
No arg validation in ipcMain.handleRenderer is an untrusted client; you trust its inputType-check + bound every arg before acting
webSecurity: false to "fix CORS"Disables same-origin policy app-wideKeep true; proxy/handle CORS in main
CSP only in a <meta> tagDoesn't cover the initial document; bypassableSet CSP in onHeadersReceived
Loading a remote URL into a Node-enabled windowRemote site runs with your app's privilegeBundle UI locally; sandbox + nav lockdown for remote
@electron/remote importSync main-object access = renderer→main RCEReplace with explicit ipcMain.handle channels
new BrowserView(...)Deprecated since Electron 30, will be removednew WebContentsView(...)
Auto-update with an unsigned/un-notarized buildSquirrel.Mac silently refuses; no updates shipSign + notarize (mac), Authenticode (win) first
Shipping on an EOL Electron majorUnpatched Chromium CVEs in your users' handsStay within the latest 3 majors
Heavy CPU work in the main processBlocks the event loop → the whole UI freezesutilityProcess/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.

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026

ahel recommends instead

Advanced
Catalog kind
skill
Gateway key
electron-ericrisco
Source
github.com/ericrisco/rsc-harness