In this article
August 13, 2026
August 13, 2026

Add authentication to your Electron app in three calls

Wiring AuthKit through Electron's main, preload, and renderer processes, without shipping a secret in your binary.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Desktop auth has a reputation, and it earned it. A browser app has one execution context, one place to keep a session, and a redirect model the whole web agrees on. Electron has three isolated contexts, no cookie jar you can rely on, and no http:// callback to redirect to. So teams end up hand-wiring PKCE, a custom URL scheme, an IPC contract, and some encrypted store, and every one of those is a place to get security wrong.

@workos/authkit-electron collapses that into one call per process context. This tutorial builds a small but complete app: sign in through the system browser, read the user in a React renderer, call a backend with an access token, and sign out cleanly. Budget about twenty minutes.

What you need

  • Electron 30 or newer, and Node 20 or newer
  • A bundler that supports ES modules. The package is ESM only, with no CommonJS build. We will use electron-vite
  • A React renderer, if you want the /react bindings. Main and preload are framework agnostic
  • A WorkOS account

Step 1: Scaffold the app

  
pnpm create @quick-start/electron authkit-desktop --template react-ts
cd authkit-desktop
pnpm install
  

That gives you the standard electron-vite layout: src/main, src/preload, src/renderer. Those three directories map exactly onto the three calls you are about to make.

Now add the SDK:

  
pnpm add @workos/authkit-electron @workos-inc/node
  

@workos-inc/node is a peer dependency (^10.4.0). If you use the React bindings, react and react-dom (v18 or v19) are peers too, and the scaffold already has them.

Step 2: Configure WorkOS

Two things in the WorkOS dashboard, and one thing you will not need.

First, copy your Client ID (it starts with client_). You do not need an API key. This library treats your desktop app as a public OAuth client, which is the right call: anything you ship in a binary is readable by anyone who downloads it, so the design assumes there is no secret to protect.

Second, under Redirects, add a custom protocol redirect URI:

  
workos-auth://callback
  

Note the scheme. Desktop apps capture the OAuth callback through a custom URL scheme rather than an http:// URL, so this has to be a scheme://path value. The SDK reads the scheme off this string and registers your app as its OS handler, so you never configure the scheme in two places.

Third, still under Redirects, set a default Logout URI. Signing out is not just a local operation. There is a hosted AuthKit session with a cookie living in the browser, and signOut ends it by navigating there, which then lands on this URI.

Then put the client ID somewhere your main process can read it. electron-vite exposes variables prefixed with MAIN_VITE_ to the main process:

  
# .env
MAIN_VITE_WORKOS_CLIENT_ID=client_...
  

Unlike the web SDKs, this library reads configuration from the createAuthKit({...}) argument rather than from the environment directly. How you source the value is up to you.

Step 3: The main process

The main process owns the session. This is where the refresh token lives and where it stays.

  
// src/main/index.ts
import { app, BrowserWindow } from 'electron';
import { join } from 'node:path';
import { createAuthKit } from '@workos/authkit-electron';

// The single-instance lock is your job, not the SDK's, and it has to run before
// your first window so the second-instance deep-link branch can fire on
// Windows and Linux.
if (!app.requestSingleInstanceLock()) {
  app.quit();
}

// Safe at module top level: createAuthKit() does no OS keychain work until the
// first sign-in, so it does not need app.whenReady().
const authkit = createAuthKit({
  clientId: process.env.MAIN_VITE_WORKOS_CLIENT_ID!,
  redirectUri: 'workos-auth://callback',
});

app.whenReady().then(() => {
  // Registers the custom protocol and wires the cross-platform deep-link
  // capture. Must run inside whenReady, before your first window, so a callback
  // URL that arrives at cold start is still handled.
  authkit.registerProtocol();

  new BrowserWindow({
    width: 900,
    height: 640,
    webPreferences: {
      preload: join(__dirname, '../preload/index.mjs'),
      // contextIsolation is on by default and is the supported mode.
    },
  });
});

app.on('will-quit', () => {
  authkit.cleanup();
});
  

Three things worth pausing on.

createAuthKit validates clientId at construction and throws if it is missing or blank. That is deliberate. The alternative is a malformed authorization URL that fails at first sign-in, in front of a user, with a worse error message.

registerProtocol() is the piece that would otherwise be a research project. Deep link delivery differs by platform: macOS fires open-url, Windows and Linux forward the URL through second-instance, and in development it can arrive in argv. The SDK wires all three branches.

You never passed a cookiePassword. The SDK seals in-flight PKCE state with a 32 character minimum secret, and on first run it generates a per install secret and stores it in the OS keychain through safeStorage. Pass one explicitly only if you need a fixed shared value across installs.

Step 4: The preload script

One line.

  
// src/preload/index.ts
import { exposeAuthKit } from '@workos/authkit-electron/preload';

exposeAuthKit();
  

This mounts a typed, contextBridge isolated API on window.__authkit_electron using IPC channel names the SDK owns. You do not declare channel names, which means you cannot typo one, and the renderer cannot reach any channel the SDK did not intend to expose.

Step 5: The renderer

Wrap the app in the provider and read state from the hook.

  
// src/renderer/src/App.tsx
import { AuthKitProvider, useAuth } from '@workos/authkit-electron/react';

export default function Root() {
  return (
    <AuthKitProvider>
      <App />
    </AuthKitProvider>
  );
}

function App() {
  const { user, isLoading, error, signIn, signOut } = useAuth();

  if (isLoading) return <p>Loading…</p>;

  if (!user) {
    return (
      <>
        <button onClick={() => signIn()}>Sign in</button>
        {error && <p role="alert">Sign-in failed: {error.message}</p>}
      </>
    );
  }

  return (
    <>
      <p>Welcome back, {user.firstName}</p>
      <button onClick={() => signOut()}>Sign out</button>
    </>
  );
}
  

Run it with pnpm dev, click Sign in, and your default browser opens to the hosted AuthKit page. Authenticate, and the OS asks whether your domain may open your app. Say yes, and you land back in the window signed in.

That confirmation dialog is expected, not a bug. It is the operating system handing the workos-auth:// callback from an external browser to your app, and it is what makes the handoff safe. Users can check "Always open" to skip it next time. If you would rather keep the whole flow inside your app with no dialog at all, see step 8.

Note the error field. Sign-in can fail quietly: the user cancels, the request is denied, the code exchange fails, or the app is offline and the auth page never loads at all. In every one of those cases the main process broadcasts a safe { code, message } payload, never tokens, that surfaces as useAuth().error. It clears on the next successful sign-in. Without it, a cancelled sign-in looks identical to being signed out, which is a support ticket waiting to happen.

For structural cases, the declarative guards read better than a ternary:

  
import { SignedIn, SignedOut } from '@workos/authkit-electron/react';

<SignedIn>
  <UserMenu />
</SignedIn>
<SignedOut>
  <SignInButton />
</SignedOut>
  

Neither renders its children until the first getUser() resolves, so you never flash the wrong state.

Step 6: Call your backend

Your app almost certainly needs to talk to an API. Use useAccessToken(), which fetches and caches the short lived access token, refetches on every auth change, and gives you a manual refresh().

  
import { useAccessToken } from '@workos/authkit-electron/react';

function ApiButton() {
  const { accessToken, isLoading, error } = useAccessToken();

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;
  if (!accessToken) return <p>Not authenticated</p>;

  async function callApi() {
    await fetch('https://api.example.com/data', {
      headers: { Authorization: `Bearer ${accessToken}` },
    });
  }

  return <button onClick={callApi}>Call API</button>;
}
  

Calling refresh() makes the main process validate the session and silently refresh the tokens if the access token has expired. What crosses IPC is the access token only. The refresh token never does. Treat the access token as the credential it is: use it in an Authorization header, and do not park it in localStorage.

Step 7: Multi-tenant, if you need it

  
const { switchToOrganization } = useAuth();

await switchToOrganization('org_123');
  

This re-issues an org scoped session, and the new organizationId and claims arrive by broadcast. Worth knowing: the main process is the single source of truth, and auth changes broadcast to every window. Sign out in one window and all of them update.

You can also scope a sign-in up front, or send someone straight to registration:

  
signIn({ organizationId: 'org_123' });
signIn({ screenHint: 'sign-up' });
  

Step 8: Pick a sign-in ceremony

A "ceremony" is how the user gets to AuthKit. The default, system-browser, is what you have been using. It is the most secure option because it runs in the user's real browser with their real session and password manager. The tradeoffs are the OS confirmation dialog on return, and a brief browser flash on sign-out, since the hosted session's cookie lives there and ending it means going to the logout URL.

If you want the flow to stay inside your app:

  
createAuthKit({
  clientId,
  redirectUri: 'workos-auth://callback',
  ceremony: { mode: 'window' },
});
  

This opens a child BrowserWindow on the hosted AuthKit page and captures the callback by intercepting in-app navigation. No OS handoff, no dialog, and sign-out is invisible because the logout URL loads in a hidden window. Passkeys still work, because the page loads at a real https:// origin, no native module required. One caveat: Touch ID inside a BrowserWindow needs Electron 42 or newer plus app.configureWebAuthn. In system-browser mode it is a non-issue.

Step 9: Package it

registerProtocol() registers the scheme at runtime, which covers the running app. For a packaged build the OS also needs the scheme declared in your build config, or a cold-start deep link has nowhere to go. With electron-builder:

  
# electron-builder.yml
protocols:
  - name: WorkOS AuthKit
    schemes:
      - workos-auth
  

If a deep link never returns to your packaged app, this block is the first thing to check.

What the SDK is actually doing for you

Roughly a week of work you did not have to do, and a set of decisions you did not have to get right on the first try.

  • The refresh token never leaves the main process. Every renderer-facing payload is built by a single chokepoint that strips fields by allowlist, so a new field reaches the renderer only if someone adds it on purpose. No IPC channel returns the refresh token.
  • Sessions are encrypted at rest with Electron's safeStorage, backed by the OS keychain, not a key compiled into your app. If safeStorage is unavailable, on a headless Linux box with no keyring, say, the SDK refuses to persist and throws EncryptionUnavailableError rather than quietly writing secrets in plaintext. There is a createDefaultStorage({ allowPlaintext: true }) escape hatch for local development. It should never reach a distributed build.
  • PKCE with sealed CSRF state. The state is encrypted, persisted main-side rather than in a cookie, and verified on callback with a constant-time compare. The pending verifier is single use with a ten minute TTL, so a replayed callback cannot reuse a consumed one.
  • Sign-out is complete. The local session clears immediately, then the hosted AuthKit session is ended wherever its cookie lives. The remote step is best effort and never blocks the local clear, so a network failure cannot strand a user in a half-signed-out state.

All the cryptography, PKCE, JWT verification, and token refresh comes from @workos/authkit-session. This library re-implements none of it.

Not using React?

The React bindings are optional. Main and preload are framework agnostic, and the preload bridge is the public renderer API. Once exposeAuthKit() has run, any renderer can drive auth through window.__authkit_electron.

Opt into typing from any .d.ts in your renderer source:

  
/// <reference types="@workos/authkit-electron/globals" />
  

The bridge is typed as optional, because it is genuinely absent when the preload did not run. Guard once and reuse:

  
// renderer/authkit.ts
import type { AuthKitBridge } from '@workos/authkit-electron/preload';

export function authkit(): AuthKitBridge {
  const bridge = window.__authkit_electron;
  if (!bridge) {
    throw new Error('[authkit-electron] bridge missing, did the preload call exposeAuthKit()?');
  }
  return bridge;
}
  

Every renderer to main call resolves to an IpcResult<T>, so branch on ok rather than wrapping in try/catch, and read the stable error.code on failure:

  
const result = await bridge.getUser();
if (!result.ok) {
  console.error(result.error.code);
  return;
}

// Sign-in, sign-out, and org switches in any window arrive here.
// onAuthChange returns an unsubscribe function.
bridge.onAuthChange((payload) => render(payload.user));
  

onAuthChange delivers the same payload the React hooks consume, so everything in useAuth() maps one to one onto bridge calls. Building a Vue composable or a Svelte store means wrapping the same six methods, which is all AuthKitProvider does.

When something breaks

  • clientId is required at startup. The value was undefined or blank, nearly always a missing or misnamed environment variable that your bundler does not expose to the main process. Check the MAIN_VITE_ prefix.
  • window.__authkit_electron is missing. The renderer cannot see the bridge. Confirm exposeAuthKit() runs in your preload script and that webPreferences.preload points at that file.
  • EncryptionUnavailableError. Either Linux without a keyring (install or start libsecret or gnome-keyring), or something touched safeStorage before app.whenReady(). createAuthKit() itself is safe at module top level because it defers the keychain read, but a custom TokenStorage that reads eagerly is not.

Go build something

A complete runnable app lives in example/ in the repo, covering all three contexts and both ceremony modes.

The library is MIT licensed and issues and pull requests are welcome.