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.
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
/reactbindings. Main and preload are framework agnostic - A WorkOS account
Step 1: Scaffold the app
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:
@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:
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:
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.
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.
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.
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:
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().
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
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:
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:
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:
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. IfsafeStorageis unavailable, on a headless Linux box with no keyring, say, the SDK refuses to persist and throwsEncryptionUnavailableErrorrather than quietly writing secrets in plaintext. There is acreateDefaultStorage({ allowPlaintext: true })escape hatch for local development. It should never reach a distributed build. - PKCE with sealed CSRF state. The
stateis 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:
The bridge is typed as optional, because it is genuinely absent when the preload did not run. Guard once and reuse:
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:
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 requiredat startup. The value wasundefinedor blank, nearly always a missing or misnamed environment variable that your bundler does not expose to the main process. Check theMAIN_VITE_prefix. window.__authkit_electron is missing. The renderer cannot see the bridge. ConfirmexposeAuthKit()runs in your preload script and thatwebPreferences.preloadpoints at that file.EncryptionUnavailableError. Either Linux without a keyring (install or start libsecret or gnome-keyring), or something touchedsafeStoragebeforeapp.whenReady().createAuthKit()itself is safe at module top level because it defers the keychain read, but a customTokenStoragethat 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.
- Repo: https://github.com/workos/authkit-electron
- AuthKit docs
@workos/authkit-session, the framework agnostic core