Sik.limited Logo

Electron security design: how to make preload·contextBridge·IPC narrow

We've outlined how to design the boundary between renderer and OS permissions in your Electron app. It creates a narrow contract for preload, contextBridge, and IPC, and provides code and checklists for safely handling files, external links, and remote content.

Sik ·

When you first attach Electron, the screen will appear quickly. This is because you can just import the React·Svelte·Vue and Node.js packages used on the web. But when the app opens files, pops up OS notifications, opens external links, and starts receiving automatic updates, the name of the problem changes. Instead of asking “what can I allow the renderer to do?” it becomes “how much can I allow a broken or unexpected renderer to do?”

To conclude this article, Electron security is not a matter of one line of configuration. preload is a gateway for passing permissions, contextBridge is a contract for a public API, and IPC should be designed like a server API. Narrowing these three does not give you less features, but rather creates an app that can be reviewed even with more features.

If you are already worried about choosing a desktop framework, Tauri vs Electron: Criteria for selecting a SvelteKit desktop app is also worth reading first. Here, assuming you have chosen Electron, we will only cover how to safely operate that choice.

A security perimeter is a flow of permissions, not a window

Electron apps usually have three types of code:

LocationJob responsibilitiesbasic attitude
main processWindows, File System, OS API, IPC HandlerWhere to run permissions
preloadLimited connection between renderer and mainWhere to translate functions
rendererScreens, user input, web contentWhere to deal without trust

The most common misconception is that you can trust the renderer because it opens a local bundle with loadFile(). However, external input comes in faster than expected inside the app, such as markdown previews, pasted HTML, external OAuth windows, link previews, and plugin UI. If an XSS occurs and the renderer can use require('fs') or send IPC names arbitrarily, the screen bug will grow into a desktop permission problem.

So it is better to read the security model this way.

사용자 입력·웹 콘텐츠
          ↓
      renderer
          ↓  (의도와 데이터가 제한된 API)
       preload
          ↓  (채널·스키마·발신 창 검증)
         main
          ↓
  파일·키체인·OS·네트워크

Here preload is not a mini backend. It is not a place to store heavy file processing or permission decisions. It's more of an adapter that declares a small list of functions that the renderer can call. The actual authority determination must be made once more in main.


Specify the default values of the window in the code

Recently, Electron has turned on context isolation and renderer sandbox by default, but it is better not to rely solely on the default values. This is because the setup may vary as the team upgrades, replaces the boilerplate, or adds special windows. In particular, the code that creates new windows is often the first place to look in security reviews.

// main/window.ts
import { BrowserWindow } from 'electron'
import path from 'node:path'

export function createMainWindow() {
  return new BrowserWindow({
    width: 1280,
    height: 840,
    show: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      sandbox: true,
      nodeIntegration: false,
      webSecurity: true,
      webviewTag: false,
      enableBlinkFeatures: '',
      allowRunningInsecureContent: false
    }
  })
}

This setting is not certified as “completely secure.” However, it prevents the renderer from using the Node runtime directly, separates the preload from the page's JavaScript global object, and makes the sandboxed renderer go through IPC to obtain high privileges. The important thing is that you don't just end up with nodeIntegration: false. If you turn off context isolation, the strong API of preload can be mixed with page-side global objects, and the benefits of the sandbox will be weakened.

For apps with multiple types of windows, locking just one main window is not enough. “Brief windows” such as the login window, help window, PDF preview, and error report window should have the same policy. If you need to display content with different trust levels, it is better to separate them into separate windows with a dedicated preload and a dedicated permission list, rather than mixing them in the same BrowserWindow.

URL movement and new windows are also treated as permission requests

Rather than letting the renderer automatically open an external URL in a new window, it is safer to have main check the host and then pass it to the system browser. It's easy to assume that shell.openExternal() accepts a URL string, but passing an untrusted string can lead to dangerous protocols or unintended destinations.

import { shell } from 'electron'

const ALLOWED_ORIGINS = new Set([
  'https://accounts.example.com',
  'https://docs.example.com'
])

function isAllowedExternal(urlString: string) {
  try {
    const url = new URL(urlString)
    return url.protocol === 'https:' && ALLOWED_ORIGINS.has(url.origin)
  } catch {
    return false
  }
}

mainWindow.webContents.setWindowOpenHandler(({ url }) => {
  if (isAllowedExternal(url)) void shell.openExternal(url)
  return { action: 'deny' }
})

mainWindow.webContents.on('will-navigate', (event, url) => {
  if (url !== mainWindow.webContents.getURL()) {
    event.preventDefault()
    if (isAllowedExternal(url)) void shell.openExternal(url)
  }
})

Whitelisting is a product policy. Allowing all https: URLs right away is similar to not doing any validation at all. Even in dynamically determined cases like OAuth, first check whether you can explicitly narrow down the redirect URI, host, and path prefix. This code is both a security feature and serves to document product behavior.

preload is a list of features, not IPC plumbing

The code below is convenient, but not secure.

// 하지 말 것
contextBridge.exposeInMainWorld('electron', {
  send: ipcRenderer.send,
  invoke: ipcRenderer.invoke,
  on: ipcRenderer.on
})

This exposure allows any code in the renderer to send messages to arbitrary channels or subscribe to events that were not originally intended to be delivered to the screen. The moment the premise that “the only renderer of our app is our code” is broken, the entire IPC becomes the public API of the renderer. Electron's official guide also recommends exposing one function per message instead of a raw IPC object.

A good bridge only says verbs that the UI actually needs. Channel names are not exposed to the UI, and inputs are also normalized across function boundaries.

// preload.ts
import { contextBridge, ipcRenderer } from 'electron'

type SaveTextInput = {
  suggestedName: string
  content: string
}

function asSaveTextInput(value: unknown): SaveTextInput {
  if (!value || typeof value !== 'object') throw new Error('잘못된 저장 요청입니다.')

  const input = value as Record<string, unknown>
  if (typeof input.suggestedName !== 'string' || typeof input.content !== 'string') {
    throw new Error('저장할 텍스트 형식이 아닙니다.')
  }

  return {
    suggestedName: input.suggestedName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80),
    content: input.content.slice(0, 1_000_000)
  }
}

const desktop = {
  chooseTextFile: () => ipcRenderer.invoke('file:choose-text'),
  saveText: (input: unknown) => ipcRenderer.invoke('file:save-text', asSaveTextInput(input)),
  getAppVersion: () => ipcRenderer.invoke('app:version'),
  openHelp: () => ipcRenderer.invoke('navigation:open-help')
}

contextBridge.exposeInMainWorld('desktop', desktop)

This pattern seems a bit verbose. But as your app grows, this verbosity becomes beneficial. If you search for desktop.saveText(), you can find the product function requested by the renderer, and if you search for file:save-text, you can find the place where permissions are executed. Multiple domain functions are much better for auditing and deleting than a single general-purpose RPC sending and receiving random strings.

You must also include TypeScript types. Types do not replace runtime validation, but they reduce the UI from guessing and calling the bridge's hidden functions.

// renderer-env.d.ts
export {}

declare global {
  interface Window {
    desktop: {
      chooseTextFile(): Promise<{ name: string; content: string } | null>
      saveText(input: { suggestedName: string; content: string }): Promise<void>
      getAppVersion(): Promise<string>
      openHelp(): Promise<void>
    }
  }
}

Events contract for unsubscription, not callbacks.

When creating an event API, it is better not to pass Electron's event object to the renderer. There is no reason to propagate sender-related objects unnecessarily, and the UI only needs to know the message payload. Returning an unsubscribe function also prevents the problem of listeners piling up after a screen transition.

// preload.ts
const desktop = {
  onUpdateProgress(callback: (progress: number) => void) {
    const listener = (_event: Electron.IpcRendererEvent, progress: unknown) => {
      if (typeof progress === 'number' && Number.isFinite(progress)) {
        callback(Math.max(0, Math.min(progress, 100)))
      }
    }

    ipcRenderer.on('update:progress', listener)
    return () => ipcRenderer.removeListener('update:progress', listener)
  }
}
// renderer component lifecycle 예시
const unsubscribe = window.desktop.onUpdateProgress(setProgress)

// 컴포넌트가 사라질 때
unsubscribe()

If an event needs to be broadcast throughout the app, clearly state who publishes it and who receives it. invoke/handle is easier to read for request-responses, such as file save completion, and it is better to choose events only when main must speak first, such as update progress.


main's IPC handler is a small server endpoint

Since it has been verified by bridge, you should not think that you can trust main. The preload is part of the distribution, and the handler can be called from other windows or from later code changes. The main handler must check the following four things, just like when creating an HTTP API.

  1. Who sent it?
  2. What action was requested?
  3. Are the shape and range of the data correct?
  4. To what extent will results and errors be disclosed?

First, check the sender. It may feel cumbersome in early apps with only one window, but as the number of settings windows, authentication windows, and preview windows increases, it becomes the cheapest line of defense.

import { BrowserWindow, dialog, ipcMain } from 'electron'
import fs from 'node:fs/promises'

function assertMainWindow(event: Electron.IpcMainInvokeEvent) {
  if (event.sender.id !== mainWindow.webContents.id) {
    throw new Error('허용되지 않은 창의 요청입니다.')
  }
}

ipcMain.handle('file:save-text', async (event, input: unknown) => {
  assertMainWindow(event)

  if (!input || typeof input !== 'object') {
    throw new Error('저장 요청이 올바르지 않습니다.')
  }

  const { suggestedName, content } = input as Record<string, unknown>
  if (typeof suggestedName !== 'string' || typeof content !== 'string') {
    throw new Error('저장 요청이 올바르지 않습니다.')
  }

  const result = await dialog.showSaveDialog(BrowserWindow.fromWebContents(event.sender)!, {
    defaultPath: `${suggestedName}.txt`,
    filters: [{ name: 'Text', extensions: ['txt', 'md'] }]
  })

  if (result.canceled || !result.filePath) return { saved: false }
  await fs.writeFile(result.filePath, content, 'utf8')
  return { saved: true }
})

The important choice here is that the renderer does not pass an absolute path. The renderer only says “I want to save this content” rather than “Save my document to this path,” and the OS file dialog and final path are handled by main. If your product needs actually require a specific working folder, path.resolve() it from an allowed root and then make sure you don't leave the root.

import path from 'node:path'

function resolveInside(root: string, requested: string) {
  const absoluteRoot = path.resolve(root)
  const target = path.resolve(absoluteRoot, requested)

  if (target !== absoluteRoot && !target.startsWith(`${absoluteRoot}${path.sep}`)) {
    throw new Error('허용된 작업 폴더 밖의 경로입니다.')
  }

  return target
}

The mistake of appending strings containing ../../ without this verification often occurs in file access functions. Narrow down the file name, extension, size, and encoding to fit your product needs. “Allow it all in case you need it later” is the most expensive technical debt in IPC.

Design the command model before the channel name

If you give the channel a broad name like fs, system, or data, option objects will continue to grow within it. After a few months, the renderer will effectively become a mini shell. Conversely, role sharp commands sharpen the data to be accepted.

Wide and dangerous APINarrow, Reviewable API
ipc.invoke('fs:write', path, contents)desktop.saveExport({ format, content })
ipc.invoke('shell:open', url)desktop.openDocumentation()
ipc.invoke('process:exec', command)desktop.revealExportFolder()
ipc.send('config:set', key, value)settings.updateTheme(theme)

The second column may seem less functional. In fact, permissions are defined in the product language. For example, revealExportFolder() gives the user the results they need without exposing OS commands to the outside world. In change reviews, “Why do you need shell access?” Instead, we end up discussing “Why do I need to open the export folder?”

The policy changes the moment you load remote content.

Windows that open only app bundles and windows that open external URLs should not share the same security profile. This is especially true if you display remote help, payment pages, OAuth, user-generated HTML, ads, or document previews.

  • Avoid preloading remote content windows as much as possible.
  • If you must attach it, create a bridge dedicated to external pages and do not include the app's file, keychain, or update functions.
  • Permission requests are processed on a session basis, and permissions such as notifications, media, and camera are not automatically approved.
  • Set up CSP and specify page navigation, new window, and download policies.
  • User HTML is not directly injected into the app's renderer DOM as innerHTML. Choose the approach that suits your product: a sandboxed iframe, a secure Markdown renderer, or a proven sanitizer.
import { session } from 'electron'

session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
  const origin = new URL(webContents.getURL()).origin
  const allowed = origin === 'https://accounts.example.com'
    && permission === 'notifications'

  callback(allowed)
})

This example only allows notifications to a specific origin. In an actual app, it is simpler to first write “Why does this window need this permission” as a product request and not create a handler if there is no request. Permission policies should be left as a single line in the function table to prevent similar permissions from being inadvertently expanded in the future.


Security checks are conducted at the time of feature addition, not immediately before release.

Secure settings break down little by little over time. A new library requires webview, an urgent bug fix suggests contextIsolation: false, an ad-hoc diagnostic command becomes general-purpose IPC, and so on. Therefore, in practice, it is more realistic to include “Electron security inspection” in the PR checklist rather than placing it as a separate large project.

change situationQuestions to ask
New BrowserWindowDoes this window only open local bundles or external content? Is a dedicated preload necessary?
New IPCDid the renderer choose the OS permissions directly? Is the input also validated in main?
New file featuresHow do you handle absolute paths, moving parent directories, and symbolic links?
New external linkExactly which scheme·origin·path can I open?
New plugins/previewsCan untrusted JS see the app bridge?
Electron UpgradeHave you checked for security defaults, breaking changes, and package vulnerabilities?

Simple tests can also go a long way. Automate whether it naturally fails when you call a non-existent window.desktop method in the renderer, whether main rejects it when you call IPC from a window that is not allowed, whether it fails when you enter a path like ../../secret, or whether there is no file saving API on the remote page.

// 의사 코드: E2E에서 확인할 경계
expect(await mainWindow.evaluate(() => typeof window.desktop.saveText)).toBe('function')
expect(await remoteWindow.evaluate(() => 'desktop' in window)).toBe(false)

await expect(
  invokeFromSettingsWindow('file:save-text', { suggestedName: 'x', content: 'x' })
).rejects.toThrow('허용되지 않은 창')

Security is not an “advanced function that completely blocks intruders,” but rather a design that reduces the radius of damage in the event of an accident. Context isolation narrows the path for infiltrated scripts to preload, narrow bridges reduce the functions the script can request, and main verification takes final execution rights. Because the three layers repeat the same principle, if one makes a mistake, the entire app won't open right away.

Final note: Don't hide the functionality, name the permission

Electron apps have both web UI and desktop permissions. So putting all convenience functions in preload is fast at first, but as the product grows, it becomes difficult for anyone to say with confidence what is possible.

Good standards are simple. The renderer is given only functions that can be explained by the user's actions. preload turns the function into a clear IPC request. main checks again which window requested the action with which data and then executes it. Maintaining this flow ensures that your security and development experiences do not conflict. Rather, the boundaries between features become clearer, making the speed at which new features can be created, removed, and tested more stable.

Official Documentation

Latest posts