Claude Code with Electron: Main Process, Preload, IPC, and Security
Why Electron needs stricter CLAUDE.md scaffolding than web frameworks
Electron runs most of the major desktop apps you use every day. VSCode, Slack, Discord, Figma, 1Password, Notion, and dozens of others ship on it. That ubiquity is partly what makes Electron development with Claude Code tricky: Claude knows Electron well, but it knows every version of Electron, including the older patterns that shipped before context isolation became the default and before the remote module was deprecated.
Without project-specific rules, Claude will generate working Electron code that violates modern security defaults. It will set nodeIntegration: true when you describe a feature that needs Node access. It will set contextIsolation: false because that removes the constraint that requires a preload script. It will use shell.openExternal without validating the URL against an allowlist. It will let renderer-side code pass arbitrary paths to main-process file operations. Each of these produces code that compiles, runs, and is a security problem.
The root cause is the same as any framework integration: Claude cannot infer your security posture, your process boundary decisions, or which IPC channels are authorised. Without a CLAUDE.md that declares those constraints, it defaults to whatever made the official docs example the shortest. In Electron, the shortest example and the secure example diverged around Electron 5 and have not converged since.
This guide covers the CLAUDE.md configuration that pins Claude Code to secure, modern Electron patterns. If you have not set up Claude Code yet, the Claude Code setup guide covers installation and project configuration. The CLAUDE.md explained reference covers how these rules are read at session start.
What the three-process model means for Claude Code
Every Electron app has three distinct execution contexts. Getting Claude to respect their boundaries is the first thing CLAUDE.md has to establish.
The main process is a Node.js runtime. It owns the application lifecycle, creates BrowserWindow instances, handles native OS integrations, and is the only process that can call Electron APIs like dialog, shell, app, and autoUpdater. Code here has full filesystem and network access.
The renderer process runs inside a Chromium window. It is a sandboxed web environment. It cannot call Node APIs or Electron APIs directly unless you explicitly and deliberately break the sandbox, which you should not do. Code here is essentially a web page: it has access to the DOM, web APIs, and whatever you expose through the preload script.
The preload script executes before the renderer loads, in a context that has access to both the DOM and a restricted set of Node/Electron APIs. It is the bridge. Its job is to expose a narrow, explicitly defined API surface to the renderer using contextBridge.exposeInMainWorld, without leaking Node globals into the renderer's JavaScript context.
Claude will blur these boundaries without rules. It will import ipcRenderer directly in a renderer-side component file. It will put Node.js fs calls in files that get bundled into the renderer. It will suggest enabling nodeIntegration to avoid writing a preload script at all. Every one of those suggestions is a path away from the security model Electron ships with since version 12.
Add a process boundary section to CLAUDE.md before anything else:
# Process boundary rules (MUST follow)
## Main process (src/main/)
- Runs in Node.js, has full Electron and Node API access
- Owns: app lifecycle, BrowserWindow creation, IPC handlers, native OS calls
- Files: main.ts, ipc-handlers.ts, updater.ts, menu.ts
## Preload (src/preload/)
- Executes before renderer, bridges main and renderer via contextBridge only
- NEVER import ipcRenderer in renderer files, all IPC goes through preload
- NEVER leak Node globals: do not assign process, require, __dirname to window
- Files: preload.ts (one preload per window type)
## Renderer (src/renderer/)
- Runs in Chromium sandbox, treated as a web app
- ONLY accesses main-process functionality through window.api (contextBridge)
- No direct Node imports, no ipcRenderer imports, no fs, no path
- Framework: React 18 + TypeScript
## Hard rules
- nodeIntegration: false (always)
- contextIsolation: true (always)
- sandbox: true (always, unless a specific native module requires disabling, document the exception)
- Do NOT use the remote module (deprecated in Electron 14, removed in Electron 20)
- Do NOT call shell.openExternal with user-supplied URLs without allowlist validation
- Do NOT pass raw user input from renderer to main-process file operations
This block establishes the three-process model and names the specific files that belong to each. Claude generates code in the right place when it knows where each concern lives.
The CLAUDE.md template for Electron 30+
With the process model established, the full CLAUDE.md template covers the complete project configuration. This is the version that prevents the most common Claude-generated failures.
# Electron project rules
## Stack
- Electron 30.x, Node 20.x LTS
- TypeScript 5.x strict mode throughout
- React 18.x in renderer (Vite + @vitejs/plugin-react)
- electron-builder 24.x for packaging
- Zod for IPC payload validation
## Project structure
- src/main/main.ts: app entry, BrowserWindow creation, lifecycle
- src/main/ipc-handlers.ts: all ipcMain.handle() registrations
- src/main/updater.ts: electron-updater auto-update logic
- src/preload/preload.ts: contextBridge.exposeInMainWorld only
- src/renderer/: React app (Vite root is src/renderer/index.html)
- electron-builder.yml: packaging config
## BrowserWindow, required settings
Every BrowserWindow must be created with:
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: path.join(__dirname, '../preload/preload.js'),
webSecurity: true,
allowRunningInsecureContent: false,
}
- NEVER omit preload when sandbox is true, renderer will have no API surface
- NEVER set nodeIntegration: true
- NEVER set contextIsolation: false
## IPC pattern, the ONLY authorised pattern
- Main registers handlers: ipcMain.handle('channel-name', async (event, payload) => {...})
- Preload exposes: ipcRenderer.invoke('channel-name', payload)
- Renderer calls: await window.api.someMethod(payload)
- Validate payload with Zod on every ipcMain.handle before touching system resources
- IPC channels are declared in src/shared/ipc-channels.ts (const object, not strings)
- Do NOT use ipcMain.on + ipcRenderer.send for two-way communication, use invoke/handle
- Do NOT expose ipcRenderer itself through contextBridge
## Environment
- NODE_ENV: 'development' | 'production'
- app.getPath('userData'): user data directory, use this for local storage, not __dirname
- app.getPath('temp'): temp files
- .env is NOT loaded by Electron, embed build-time constants via Vite define or electron-builder extraMetadata
## Auto-updater
- Uses electron-updater (from electron-builder), not the built-in autoUpdater
- Update server: GitHub Releases (publish.provider: 'github')
- Check on app launch only, not on every window focus
- Always show user confirmation before installing (never silent install)
## Security
- allowlist for shell.openExternal: ['https://'] only, validate URL.protocol === 'https:'
- CSP via session.defaultSession.webRequest.onHeadersReceived, set on app 'ready'
- No eval(), no Function constructor in renderer
- Renderer fetches only from declared origins in CSP
## Packaging (electron-builder)
- appId: com.yourco.appname (reverse-domain, set once, never change)
- productName: 'Your App'
- mac: { category: 'public.app-category.productivity', hardenedRuntime: true, gatekeeperAssess: false }
- win: { target: 'nsis', requestedExecutionLevel: 'asInvoker' }
- linux: { target: ['AppImage', 'deb'] }
- Do NOT use asar: false without documenting why
## Hard rules (never violate without discussion)
- NEVER enable nodeIntegration
- NEVER disable contextIsolation
- NEVER import ipcRenderer in renderer-side files
- NEVER use the remote module
- NEVER pass unsanitised renderer input to fs, path, or shell operations
- NEVER call autoUpdater.quitAndInstall() without user confirmation
That is 48 lines covering stack, structure, BrowserWindow config, IPC contract, environment, auto-updater, security, and packaging. The density is deliberate: Claude reads the CLAUDE.md once per session, and each constraint you omit is a constraint Claude is free to ignore.
Two rules in this template prevent the most expensive failures.
The IPC channel constant rule matters because channel strings scattered across three layers drift. A renderer calls 'open-file', main handles 'openFile', and nothing errors at compile time. Declaring channels in src/shared/ipc-channels.ts as a typed constant object and importing it in main, preload, and renderer gives TypeScript visibility across the process boundary.
The Zod validation rule treats IPC like a network boundary. The renderer calls main with a payload. That payload crosses a trust boundary, just like an HTTP request. Without validation, Claude generates ipcMain.handle('read-file', (_, filePath) => fs.readFileSync(filePath, 'utf8')) and the renderer now controls which files main reads. With the rule, Claude adds a Zod schema before every file operation.
Preload script: the contextBridge contract
The preload script is where the API surface your renderer can see gets defined. It runs with access to ipcRenderer and must expose a narrow, intentional set of methods through contextBridge. Nothing else.
Here is the canonical preload shape to put in CLAUDE.md:
// src/preload/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
import { IPC } from '../shared/ipc-channels';
const api = {
// File operations
openFile: () =>
ipcRenderer.invoke(IPC.OPEN_FILE),
readFile: (filePath: string) =>
ipcRenderer.invoke(IPC.READ_FILE, { filePath }),
saveFile: (filePath: string, content: string) =>
ipcRenderer.invoke(IPC.SAVE_FILE, { filePath, content }),
// App info
getVersion: () =>
ipcRenderer.invoke(IPC.GET_VERSION),
// Updater
checkForUpdates: () =>
ipcRenderer.invoke(IPC.CHECK_UPDATES),
onUpdateAvailable: (callback: (info: UpdateInfo) => void) => {
ipcRenderer.on(IPC.UPDATE_AVAILABLE, (_, info) => callback(info));
return () => ipcRenderer.removeAllListeners(IPC.UPDATE_AVAILABLE);
},
// Settings
getSettings: () =>
ipcRenderer.invoke(IPC.GET_SETTINGS),
setSetting: (key: string, value: unknown) =>
ipcRenderer.invoke(IPC.SET_SETTING, { key, value }),
};
contextBridge.exposeInMainWorld('api', api);
// Type export for renderer (TypeScript only, not imported at runtime)
export type ElectronAPI = typeof api;
Three things in this preload shape are worth pinning in CLAUDE.md.
First, ipcRenderer is never re-exported or assigned to window directly. Only the wrapper functions go through contextBridge.exposeInMainWorld. If you export ipcRenderer itself, the renderer can call any channel it wants, and your IPC contract is meaningless.
Second, event listeners like onUpdateAvailable return a cleanup function. Claude will often generate ipcRenderer.on(...) calls without corresponding removeAllListeners, which leaks listeners across component mounts and hot reloads. The cleanup pattern keeps listener counts bounded.
Third, the ElectronAPI type is exported from preload and imported in the renderer for type safety. The global augmentation looks like this in src/renderer/electron.d.ts:
import type { ElectronAPI } from '../preload/preload';
declare global {
interface Window {
api: ElectronAPI;
}
}
Claude generates window.api calls without type safety when this declaration is missing. With it, TypeScript catches channel mismatches and payload shape errors at compile time, across the process boundary.
IPC handlers in the main process
IPC handler registration lives in src/main/ipc-handlers.ts, separated from main.ts to keep the entry point readable. Each handler validates its payload, does exactly one thing, and delegates to a service layer for anything more complex.
// src/main/ipc-handlers.ts
import { ipcMain, dialog, shell } from 'electron';
import { promises as fs } from 'node:fs';
import { z } from 'zod';
import { IPC } from '../shared/ipc-channels';
const ReadFileSchema = z.object({
filePath: z.string().min(1).max(4096),
});
const SaveFileSchema = z.object({
filePath: z.string().min(1).max(4096),
content: z.string(),
});
const SetSettingSchema = z.object({
key: z.enum(['theme', 'fontSize', 'spellCheck', 'autoSave']),
value: z.union([z.string(), z.number(), z.boolean()]),
});
export function registerIpcHandlers(): void {
ipcMain.handle(IPC.OPEN_FILE, async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
ipcMain.handle(IPC.READ_FILE, async (_, rawPayload) => {
const { filePath } = ReadFileSchema.parse(rawPayload);
return fs.readFile(filePath, 'utf8');
});
ipcMain.handle(IPC.SAVE_FILE, async (_, rawPayload) => {
const { filePath, content } = SaveFileSchema.parse(rawPayload);
await fs.writeFile(filePath, content, 'utf8');
return true;
});
ipcMain.handle(IPC.GET_VERSION, () => {
const { app } = require('electron');
return app.getVersion();
});
ipcMain.handle(IPC.SET_SETTING, async (_, rawPayload) => {
const { key, value } = SetSettingSchema.parse(rawPayload);
// write to electron-store or similar
await settingsStore.set(key, value);
return true;
});
}
The IPC.OPEN_FILE handler shows the right pattern for filesystem access: let the OS file picker choose the path, then read that path. The renderer never supplies a path for this operation. For IPC.READ_FILE, the renderer does supply a path (say, from a recent-files list), so the schema validates it is a non-empty string within a reasonable length before it touches fs. You can add path validation using path.resolve and checking it starts within an expected root directory if your use case warrants it.
The Zod parse() call throws a ZodError on invalid input. Electron will propagate that error back to the renderer through the invoke promise rejection. The renderer receives an error it can display. It does not crash the main process.
The SET_SETTING handler uses a strict enum for the key. This is the correct pattern for any IPC handler that writes to a config store: the renderer cannot invent new settings keys by passing arbitrary strings. Without the enum, Claude generates store.set(key, value) with no constraint on key, and the renderer can write to any path in the settings object.
For patterns that touch TypeScript across multiple files in this configuration, the broader conventions in Claude Code with TypeScript apply directly to how Claude resolves types across the main, preload, and renderer contexts.
Packaging with electron-builder
Packaging is where Electron projects accumulate silent technical debt. electron-builder handles macOS, Windows, and Linux targets, notarisation, auto-update infrastructure, and code signing. Claude will generate a minimal electron-builder.yml that works for local builds and misses the settings that matter for distribution.
Add the packaging config to CLAUDE.md:
# electron-builder.yml
appId: com.yourco.appname
productName: YourApp
copyright: "Copyright 2026 Your Company"
directories:
output: dist
files:
- dist/main/**
- dist/preload/**
- "!src/**"
- "!**/*.ts"
- "!node_modules/**"
- node_modules/electron-updater/**
asar: true
mac:
category: public.app-category.productivity
hardenedRuntime: true
gatekeeperAssess: false
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
target:
- target: dmg
arch: [x64, arm64]
win:
target:
- target: nsis
arch: [x64]
requestedExecutionLevel: asInvoker
linux:
target:
- AppImage
- deb
category: Utility
publish:
provider: github
owner: your-org
repo: your-repo
nsis:
oneClick: false
allowDirChange: true
createDesktopShortcut: always
Four settings in this config are consistently wrong or missing in Claude-generated electron-builder configurations.
hardenedRuntime: true is required for macOS notarisation, which is required for macOS Catalina and later. Without it, your app gets quarantined on download. Claude often omits this because the official getting-started examples skip the full notarisation path.
asar: true bundles your app code into an asar archive. Claude sometimes sets asar: false on legacy grounds. Leave it true unless you have a native module that cannot be asared and document the exception.
The files array excludes TypeScript source and all of node_modules except electron-updater. The default Claude generates includes too much. Your built app should not ship .ts source files or the full development node_modules tree.
The nsis block with oneClick: false gives Windows users a proper installer with path selection instead of a silent install into AppData/Local. Silent installs work but produce confused enterprise users who cannot find where the app installed.
Auto-updater configuration
electron-updater from the electron-builder ecosystem is the standard auto-update library. It handles delta updates, staged rollouts, and cross-platform update mechanics. The built-in autoUpdater from Electron itself is thinner and requires more manual assembly.
Add the updater module to CLAUDE.md:
// src/main/updater.ts
import { autoUpdater } from 'electron-updater';
import { BrowserWindow } from 'electron';
import log from 'electron-log';
import { IPC } from '../shared/ipc-channels';
autoUpdater.logger = log;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoDownload = true;
export function initUpdater(mainWindow: BrowserWindow): void {
autoUpdater.on('update-available', (info) => {
mainWindow.webContents.send(IPC.UPDATE_AVAILABLE, info);
});
autoUpdater.on('update-downloaded', (info) => {
mainWindow.webContents.send(IPC.UPDATE_DOWNLOADED, info);
});
autoUpdater.on('error', (err) => {
log.error('Updater error:', err);
});
// Check once on launch, not on every window focus
autoUpdater.checkForUpdatesAndNotify().catch(log.error);
}
// Registered via ipcMain.handle(IPC.INSTALL_UPDATE, ...)
export function installUpdate(): void {
autoUpdater.quitAndInstall();
}
Two rules in CLAUDE.md prevent the common auto-updater failures.
autoDownload: true paired with autoInstallOnAppQuit: true is the right default for most apps: download happens in the background, install happens when the user next quits. The user sees one notification (update downloaded, will install on next quit) and is never interrupted. Claude sometimes generates autoUpdater.quitAndInstall() on update-downloaded with no user confirmation, which force-quits the running app mid-work.
The installUpdate function is exposed to the renderer as ipcMain.handle(IPC.INSTALL_UPDATE, ...), which means the user can trigger install from a UI button when they choose. Mandatory auto-restart on download is the pattern that produces the most negative reviews for desktop apps.
Common Claude failure modes in Electron projects
Without CLAUDE.md, five failure modes appear consistently across Electron projects.
nodeIntegration: true. Claude generates this when you describe a feature that needs filesystem access, because it is the shortest path to making fs available in the renderer. The correct path is a preload script with a readFile handler. The rule blocks the shortcut.
contextIsolation: false. Claude generates this as a paired change with nodeIntegration: true, or independently when it generates a preload script that tries to assign Node globals to window directly. With context isolation off, the renderer and preload share a JavaScript context, which means any third-party script your renderer loads can access window.require or window.process. The rule blocks this.
ipcRenderer imported in renderer files. When Claude generates a React component that needs to call a main-process function, it will sometimes import ipcRenderer directly from electron in the component file. This produces a compile error in most Vite setups (because electron is not available in the renderer bundle) and a logic error if the build system happens to resolve it. The CLAUDE.md rule that all IPC goes through window.api prevents this.
Arbitrary shell.openExternal. Features like "open in browser" or "show in Finder" use shell.openExternal. Claude generates these with user-supplied or database-supplied URLs without protocol validation. A URL like file:///etc/passwd or a custom protocol handler can do unexpected things. The allowlist rule restricts shell.openExternal to https: URLs that match an explicit domain list, or uses shell.showItemInFolder for filesystem paths chosen by the OS dialog.
Missing Zod on IPC handlers. Claude generates clean IPC handlers in isolation. When adding a new feature, it generates the new handler and forgets the validation pattern from adjacent handlers. Putting the validation pattern explicitly in CLAUDE.md with "validate payload with Zod on every ipcMain.handle" makes Claude generate the schema alongside every new handler.
For the broader pattern of constraining Claude Code behaviour on destructive or sensitive operations, the Claude Code permissions guide covers permission hooks that can gate specific shell commands from the main process.
State sharing between main and renderer
State that needs to flow between main and renderer is the design question Electron developers spend the most time on. There are three patterns, each with a different appropriate use case.
IPC for imperative operations. The renderer asks main to do something and receives a result. ipcRenderer.invoke with ipcMain.handle is the canonical shape. Use this for file I/O, native dialogs, system information, settings reads and writes, and anything that requires main-process APIs. This is the pattern the CLAUDE.md above is built around.
Main pushing state to renderer. Main has information the renderer needs without the renderer asking: update status, system event notifications, network connectivity changes, external app events. Use mainWindow.webContents.send(IPC.CHANNEL, payload) from main and ipcRenderer.on(IPC.CHANNEL, handler) in the preload's exposure. The listener cleanup pattern from the preload section above applies here: always return the removeAllListeners function so React's useEffect cleanup can call it.
Shared persistent state. Settings, user preferences, and app state that both processes need access to, persist across restarts, and do not change frequently. Use a library like electron-store in main, expose read/write through IPC, and keep the renderer's copy in a React context or Zustand store. The renderer treats it as server state (like an API response) rather than shared mutable state.
What Claude generates without guidance is a fourth pattern that should not exist: direct global object access. Claude will sometimes suggest putting shared state on global.appState in main and accessing it from the preload's process.mainModule. This was a workaround for the absence of a clean state-sharing API in older Electron versions. It couples the preload implementation to main-process internals and breaks with sandboxing. The IPC-as-the-only-communication-channel rule blocks this pattern entirely.
For client-side state management in the renderer itself, the patterns in Claude Code with React apply directly. The renderer is a React app. Zustand, React Query, or React context manage local and server state the same way they do in a web app. The only difference is that "server" is the main process rather than an HTTP endpoint.
Native modules and packaging
Native modules are compiled addons that use Node-API (formerly nan) to call native system libraries. They work in Electron but require a compilation step against Electron's specific Node version, because the Node ABI version Electron ships is not always the same as the system Node.
Add a native module section to CLAUDE.md if your project uses them:
## Native modules
### Modules in use
- better-sqlite3: synchronous SQLite3 in main process only
- keytar: OS credential store access (main process only)
### Rebuild on npm install
"scripts": {
"postinstall": "electron-rebuild -f -w better-sqlite3"
}
### Rules
- Native modules run in main process ONLY, never require them in preload or renderer
- electron-rebuild must complete before packaging
- If adding a new native module, document it here and add to electron-rebuild -w list
- asar: false may be required for some native modules, document the specific module
Claude will generate require('better-sqlite3') in a renderer component when asked to add database access without the native module rules. The module will not load in the renderer context. The rule prevents this by establishing main-process-only as the explicit location for native module usage.
For the environment variable handling that feeds into Electron's build process, including injecting API keys and feature flags at build time via Vite's define configuration, the patterns in Claude Code with environment variables cover the mechanics of keeping secrets out of the packaged app bundle.
What Claude Code does well in Electron projects
With the CLAUDE.md above in place, several things Claude Code handles reliably.
BrowserWindow creation with the full security config is consistent. Once the required webPreferences block is in CLAUDE.md, Claude copies it to every new window without omission.
The preload script shape is reproducible. Claude generates new channel methods following the ipcRenderer.invoke pattern with appropriate TypeScript types when given an existing preload to extend.
electron-builder config for a new target is accurate. Adding Linux AppImage to an existing macOS/Windows config is a single CLAUDE.md reference away.
IPC channel additions are clean. Adding a new handler in ipc-handlers.ts, a new constant in ipc-channels.ts, and a new method in preload.ts is a three-file change Claude performs correctly when CLAUDE.md establishes that those are the three required touches.
Three areas still warrant manual review.
The BrowserWindow show: false / ready-to-show pattern catches developers who want to avoid the flash of an unstyled window. Claude generates show: true by default. The correct pattern is show: false on creation, then mainWindow.once('ready-to-show', () => mainWindow.show()). Always check new window creation code for this.
Code signing configuration is environment-specific and involves secrets that should not live in CLAUDE.md. Claude cannot generate the correct entitlements plist for macOS notarisation without knowing your Apple Developer credentials and team ID. Treat signing config as a CI/CD concern and review it manually before the first production build.
Update channel staging (stable vs. beta vs. alpha) is project policy. electron-updater supports it through allowPrerelease and channel settings. Claude will default to the stable channel without guidance. If your release process includes beta testing, add the channel policy to CLAUDE.md explicitly.
Building desktop software that holds its defaults
The Electron CLAUDE.md in this guide produces generated code that keeps contextIsolation on, keeps nodeIntegration off, routes all cross-process communication through a typed IPC contract, validates payloads at the main-process boundary, and packages for three platforms with the distribution settings that matter for production.
The pattern behind this is the same one across every framework integration with Claude Code: the generator is as good as the constraints you give it. Electron's security model is well-designed and well-documented, but it has enough surface area and enough historical variation that Claude picks the wrong default whenever the context is thin. CLAUDE.md is the context. Write it once, and every subsequent session starts with a project that fails closed on security, has a clear place for every new IPC channel, and packages correctly without a debugging session on the CI build server.
For the foundational Claude Code workflow patterns this Electron setup builds on, Claude Code best practices covers session structure, multi-file editing, and how CLAUDE.md interacts with the broader agentic loop. Claudify includes an Electron-specific CLAUDE.md template pre-configured with the process boundary rules, BrowserWindow security checklist, IPC contract, and electron-builder targets from this guide.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify