Desktop apps with web frontends combine a web UI with a native backend. On a website, a simple vulnerability may expose the user’s session. But in a desktop app, an overlooked XSS issue can reach the device and its file system.

Some frameworks restrict this access by default, while others rely on your code to keep it secure.

In this guide, we look at how frameworks in this class approach these threats. It compares MōBrowser, which we create at TeamDev, against Electron, the framework that defined this class of applications:

What can page code access? 

Desktop apps with web frontends run page code and native backend code in separate processes. The page runs in a renderer process, which displays the interface and handles user interactions. The backend runs in the main process, where it can access the filesystem and open native dialogs.

When the frontend needs a backend operation, it sends the backend a message and receives a message with the result. This exchange is called interprocess communication, or IPC.

Direct native access 

What to look for: whether code in the renderer can ever call native APIs directly.

Native access as an option. Electron disables Node.js in pages by default, though developers can enable it when needed. Once enabled, every script can call Node.js directly from the rendering process. Your team has to keep an eye on this setting to make sure it stays off.

No native runtime in the renderer. MōBrowser runs Node.js only in the main process. By design, renderer processes have no access to Node.js; this is part of the framework’s architecture, not a setting. Page code reaches backend operations only through IPC. The cost is that every native operation, however small, needs an IPC method.

Exposing backend operations 

What to look for: how the protocol between frontend and backend is defined and whether it can become broader than intended.

An API defined by bridge code. In Electron, a developer defines the methods accessible to page code in a preload script. They can expose arbitrary named methods: the protocol is whatever the bridge code implements.

The page sees a single application method:

const project = await window.desktop.openProject(projectId);

The preload script maps that method to an IPC channel:

contextBridge.exposeInMainWorld('desktop', {
  openProject: (projectId) =>
    ipcRenderer.invoke('projects:open', projectId),
});

An API defined by a contract. MōBrowser requires a strongly typed contract expressed in Protocol Buffers language. The page code can then communicate with the backend using the code generated from this contract by the framework.

The contract defines the same operation and its data:

message OpenProjectRequest {
  string project_id = 1;
}

message ProjectSummary {
  string id = 1;
  string name = 2;
}

service ProjectsService {
  rpc OpenProject(OpenProjectRequest) returns (ProjectSummary);
}

The generated client exposes the operation to page code:

const project = await ipc.projects.OpenProject({ projectId });

Which pages can access the backend? 

The applications of this kind are, essentially, browsers and can load any remote web page. These applications are essentially browsers, so they can load remote web pages. Even if you never intend them to, a bug in the client-side code could send users to a page hosted outside your app.

Only the application’s own pages and remote pages you explicitly trust should be allowed to call backend methods.

Trusted pages and IPC 

What to look for: who decides which pages can call the backend, the framework or your code.

Applications usually identify a caller by its origin. For example, in https://example.com/account, the origin is https://example.com.

Bundled pages also need an origin. Both frameworks use a custom protocol for this: Electron requires you to register one, and MōBrowser provides one out of the box.

Frameworks differ in who performs the origin check: developers write it into each request handler, or the framework applies it to every request by default.

Checks in application code. By default, Electron allows any page to send an IPC message. Checking the sender is the application’s job, which means adding a check to every privileged operation:

ipcMain.handle('projects:open', (event, projectId) => {
  if (event.senderFrame?.origin !== 'app://application') {
    throw new Error('Untrusted caller');
  }

  const project = projectCatalog.get(projectId);
  if (!project) throw new Error('Unknown project');

  return projectService.open(project);
});

Since each handler can apply its own rule, you have granular control over authorizing requests. But you need to add the check to every new handler and revisit existing checks as the code changes.

Checks in the framework. MōBrowser makes IPC available only to the application’s own pages and checks the sender’s origin in the main process before any handler runs. The service implementation does not repeat that check:

ipc.registerService(ProjectsServiceDescriptor, {
  OpenProject({ projectId }) {
    const project = projectCatalog.get(projectId);
    if (!project) throw new Error('Unknown project');

    return projectService.open(project);
  },
});

Both implementations keep the filesystem path in the backend. Page code can name only a project already known to the application.

Remote origins are admitted through one trust list in the application configuration:

{
  "app": {
    "trustedOrigins": ["https://app.example.com"]
  }
}

Maintaining a single list is easier than tracking multiple checks scattered across the codebase. But the control becomes significantly coarser. Adding an origin to the list grants IPC together with navigation, browser permissions, and a CORS exception.

What to look for: what pages and resources the application is allowed to load.

An XSS bug or an open redirect can make the application load an arbitrary page. What that page can do then depends on the boundaries described above. But even without backend access, you don’t want the application to show potentially malicious content to users.

Open by default. Electron windows can navigate beyond the application’s own pages until developers explicitly restrict them. Electron provides navigation events for the main page, iframes, and new windows.

Restricted by default. MōBrowser allows the main page to load only the built-in application origin, trusted origins, and origins served by custom protocol handlers. Developers can allow loading a remote page at runtime, without adding it to the trust list, but such pages don’t get IPC and other permissions.

Browser permissions 

What to look for: how the framework handles camera, microphone, location, and similar permission requests.

Page code can request access to the camera and other protected capabilities through standard browser APIs, without going through Node.js or IPC. Frameworks give developers control over granting or denying these permissions.

Approved unless restricted. Electron approves permission requests by default. Applications restrict them with permission check and request handlers on each session they use. Those handlers apply to every page, so an application can deny permissions even to its own pages.

The same rule should handle both permission checks and requests:

const ses = win.webContents.session;

const allowsPermission = (origin, permission) =>
  origin === 'https://maps.example.com' &&
  permission === 'geolocation';

ses.setPermissionCheckHandler((_, permission, origin) =>
  allowsPermission(origin, permission)
);

ses.setPermissionRequestHandler(
  (_, permission, respond, { requestingUrl }) =>
    respond(
      allowsPermission(new URL(requestingUrl).origin, permission)
    )
);

Granted by trust. MōBrowser automatically grants permissions to the default application origin and explicitly trusted pages. Not all permissions are granted, though. Developers can also grant specific permissions to other pages by using a permission handler.

For example, an untrusted page can be given access to location without granting it other permissions:

win.browser.handle(
  'requestPermissions',
  async ({ url, permissionType }) =>
    new URL(url).origin === 'https://maps.example.com' &&
    permissionType === 'geolocation'
      ? 'grant'
      : 'deny'
);

The automatic grant is convenient but gives you coarser control: you can’t deny automatic grants ad hoc.

Which protections can be weakened? 

Frameworks in this class build on Chromium’s browser protections. What differs is which protections can be switched off and who can switch them off: developers, coding agents, or even users.

Isolation and browser security settings 

What to look for: which protections can be disabled.

Chromium runs web content in renderer processes, separate from the main process. Its sandbox limits what those renderers can access on the operating system. Within a renderer, separate V8 worlds can keep page code apart from framework code.

Safe defaults, per-window overrides. Electron enables context isolation and the sandbox by default, but either can be turned off in a window’s preferences. Turning off the sandbox gives its preload script full Node.js access. Other preferences can relax origin protections, allow insecure content, or enable experimental browser features. Because these settings apply per window, each window configuration needs review as the application changes.

Fixed protections. MōBrowser always runs renderers in the Chromium sandbox and does not use a privileged preload bridge. Its window and view options provide no settings to change this. The trust list is the exception: the application’s pages can read responses from trusted origins without the usual CORS headers. Other pages still go through CORS checks.

Production launch controls 

What to look for: whether startup options can weaken protections in production.

An application can behave differently depending on the arguments and environment variables it receives at startup. An unsanitized command-line parameter that reaches Chromium or Node.js can weaken the application’s security without changing the application itself. The frameworks handle these inputs differently.

Configurable launch behavior. Electron gives developers several ways to configure how the application starts. By default, it accepts command-line switches and Node.js startup inputs. These can disable the sandbox, ignore certificate errors, or change how Node.js starts. Developers can turn off selected Node.js inputs with packaging-time fuses, while Chromium switches generally remain available.

Closed in production. MōBrowser does not pass Chromium switches through in production and ignores NODE_ environment variables.

Conclusion 

When choosing a framework for a desktop app with a web frontend, check how it exposes native APIs, which pages can reach the backend, how it handles navigation and browser permissions, and which protections can change in production.

Electron leaves most of these decisions to application code and configuration. IPC checks, navigation rules, permission handlers, window settings, and packaging options can be configured separately. Each one must also be reviewed as the application changes.

MōBrowser, which we develop at TeamDev, enforces more of these decisions in the framework. This leaves fewer settings in application code, but groups several capabilities under the same trust decision and allows fewer exceptions.

Both approaches still require careful control over which pages the application loads and trusts. The main difference is whether the application needs separate rules for each capability or can use a shared policy with fewer settings.

The companion CISO guide covers the organizational requirements behind these decisions.