Clerk is the easiest way to add authentication and user management to your Electron application.
Warning
@clerk/electron is in beta. APIs may change before 1.0.
The package has these entrypoints:
@clerk/electronruns in the main process.@clerk/electron/preloadruns in preload scripts.@clerk/electron/reactruns in the renderer process.@clerk/electron/storageis the default token storage, backed byelectron-store.@clerk/electron/passkeysadds passkey (WebAuthn) support to the renderer process.
// main.ts
import { app, BrowserWindow, net, protocol } from 'electron';
import { createClerkBridge } from '@clerk/electron';
import { storage } from '@clerk/electron/storage';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
const clerk = createClerkBridge({
storage: storage(),
renderer: {
scheme: 'my-app',
host: 'renderer',
},
passkeys: true,
});
if (clerk.isPrimaryInstance) {
app.whenReady().then(() => {
protocol.handle('my-app', request => {
const url = new URL(request.url);
const file = url.pathname === '/' ? 'index.html' : url.pathname;
return net.fetch(pathToFileURL(join(__dirname, '../renderer', file)).toString());
});
const win = new BrowserWindow({
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
},
});
win.loadURL('my-app://renderer/');
});
}On Windows and Linux, a deep link starts a second copy of the app, so the callback only reaches the
running process through Electron's single-instance lock. When you configure a renderer scheme,
createClerkBridge acquires that lock by default and quits secondary processes after it forwards
their command-line arguments. Call it before app.whenReady(), and stop your own bootstrap when
isPrimaryInstance is false. app.quit() is asynchronous, so whenReady can still fire in a
process that is shutting down. cleanup() releases the lock unless the application already owned
it. macOS doesn't need the lock. Deep links arrive through open-url, so Clerk takes no lock and
isPrimaryInstance is always true.
If the application manages the lock, acquire it before creating the bridge and disable Clerk's lock management:
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
createClerkBridge({
storage: storage(),
renderer: { scheme: 'my-app', host: 'renderer' },
manageSingleInstanceLock: false,
});
}Linux packages must also register the renderer scheme in their .desktop entry so the browser can
launch the app:
Exec=/path/to/my-app %U
MimeType=x-scheme-handler/my-app;In my-app://renderer/sign-in, my-app is the scheme, renderer is the host, my-app://renderer is the origin, and /sign-in is the path. If your renderer uses path-based routing, serve every route from the same origin and fall back to your renderer entrypoint as needed.
// preload.ts
import { exposeClerkBridge } from '@clerk/electron/preload';
exposeClerkBridge({ passkeys: true });// renderer.tsx
import { ClerkProvider } from '@clerk/electron/react';
import { passkeys } from '@clerk/electron/passkeys';
<ClerkProvider
publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}
passkeys={passkeys}
>
{/* ... */}
</ClerkProvider>;To set an app-specific product token such as Acme Co/1.0.0, pass userAgent to createClerkBridge before creating renderer windows. Clerk keeps Electron's platform details, such as Macintosh or Windows. UserProfile's session activity then shows your product token as the app name:
createClerkBridge({
storage: storage(),
renderer: { scheme: 'my-app', host: 'renderer' },
userAgent: 'Acme Co/1.0.0',
});Add your renderer's origins to your instance's allowed origins. Otherwise, Clerk rejects renderer requests that carry both an Origin and an Authorization header with "Setting both the 'Origin' and 'Authorization' headers is forbidden". The renderer sends both once it has a client token.
curl -X PATCH https://api.clerk.com/v1/instance \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"allowed_origins": ["my-app://renderer", "http://localhost:5173"]}'Include your custom scheme origin and, during development, your dev server's origin. The command replaces the instance's whole list, so include every origin the instance already allows. To see the current list, send a GET request to the same endpoint.
@clerk/electron loads Clerk's prebuilt UI from Clerk's CDN at runtime instead of bundling it, so your renderer's Content Security Policy must allow Clerk's Frontend API host. If it doesn't, the UI script fails to load and Clerk components never render.
Replace {fapi_host} below with your instance's Frontend API host, found in the Clerk Dashboard under API keys. It includes a clerk. segment (for example, clerk.your-app.com in production or your-slug.clerk.accounts.dev in development). Don't use the Account Portal URL (your-slug.accounts.dev). That host blocks the UI script from loading.
default-src 'self';
script-src 'self' 'unsafe-inline' https://{fapi_host} https://challenges.cloudflare.com https://*.protect.clerk.com;
connect-src 'self' https://{fapi_host} https://*.protect.clerk.com:* https://clerk-telemetry.com;
img-src 'self' https://img.clerk.com data:;
style-src 'self' 'unsafe-inline';
worker-src 'self' blob:;
frame-src 'self' https://challenges.cloudflare.com https://*.protect.clerk.com;
form-action 'self';
Note
This policy covers sign-in, sign-up, and the hotloaded UI. Clerk Billing (Stripe) and other features need more origins. See Clerk's CSP guide for the full list.
Apply it either with a <meta> tag in your renderer HTML:
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' https://{fapi_host} https://challenges.cloudflare.com https://*.protect.clerk.com; connect-src 'self' https://{fapi_host} https://*.protect.clerk.com:* https://clerk-telemetry.com; img-src 'self' https://img.clerk.com data:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; frame-src 'self' https://challenges.cloudflare.com https://*.protect.clerk.com; form-action 'self';"
/>or, as Electron's security guide recommends, as a response header from the main process:
// main.ts
import { app, session } from 'electron';
const fapiHost = 'clerk.your-app.com';
app.whenReady().then(() => {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
[
"default-src 'self'",
`script-src 'self' 'unsafe-inline' https://${fapiHost} https://challenges.cloudflare.com https://*.protect.clerk.com`,
`connect-src 'self' https://${fapiHost} https://*.protect.clerk.com:* https://clerk-telemetry.com`,
"img-src 'self' https://img.clerk.com data:",
"style-src 'self' 'unsafe-inline'",
"worker-src 'self' blob:",
"frame-src 'self' https://challenges.cloudflare.com https://*.protect.clerk.com",
"form-action 'self'",
].join('; '),
],
},
});
});
});Note
HMR needs looser rules when the renderer loads from a dev server such as Vite. Add 'unsafe-eval' to script-src and your dev server's origin to connect-src (for example, ws://localhost:<port> http://localhost:<port>). Many apps skip CSP during development and apply it only to packaged builds.
Passkeys work in two modes. Clerk picks one for each request:
- Renderer mode: when your window loads content over
https://from an origin that matches your passkey RP (Relying Party) ID, the renderer uses Chromium's built-in WebAuthn. The OS or browser syncs credentials. Windows Hello works without setup. Touch ID on macOS requires Electron 42 or later andapp.configureWebAuthn. - Native mode: when your window loads a local bundle such as
scheme://host, WebAuthn's origin checks reject the request. Clerk sends the ceremony over IPC to the main process instead. The optional@clerk/electron-passkeysnative module then calls the OS WebAuthn APIs (AuthenticationServices on macOS,webauthn.dllon Windows).
Passkey autofill relies on WebAuthn conditional mediation, which only the renderer can provide. In native mode, the sign-in form offers passkeys through the "Use passkey" action instead. It never opens a passkey prompt on its own.
Native mode requires the optional native module:
pnpm add @clerk/electron-passkeys// main process
import { createClerkBridge } from '@clerk/electron';
import { storage } from '@clerk/electron/storage';
createClerkBridge({ storage: storage(), passkeys: true });// preload script
import { exposeClerkBridge } from '@clerk/electron/preload';
exposeClerkBridge({ passkeys: true });// renderer process (React)
import { ClerkProvider } from '@clerk/electron/react';
import { passkeys } from '@clerk/electron/passkeys';
<ClerkProvider
publishableKey={publishableKey}
passkeys={passkeys}
>
{/* ... */}
</ClerkProvider>;Your app only bundles and initializes passkey code when you pass the passkeys prop. If you manage the Clerk instance yourself instead of using ClerkProvider, wire it up before clerk.load():
// renderer process (vanilla)
import { Clerk } from '@clerk/clerk-js';
import { createPasskeyProvider } from '@clerk/electron/passkeys';
const clerk = new Clerk(publishableKey);
createPasskeyProvider(clerk);
await clerk.load();Like passkeys on iOS, the macOS platform APIs require a verified association between your app and your domain:
- In the Clerk Dashboard, go to the Native applications page and ensure the Native API is enabled. Your Electron app needs it.
- Add an iOS application to the Native applications page in the Clerk Dashboard. You need your app's App ID Prefix and Bundle ID. An Electron macOS app uses the same configuration as an iOS app.
- Sign your app with
com.apple.developer.associated-domainscontainingwebcredentials:<rp-domain>. This is a restricted entitlement. The build must embed a provisioning profile with the Associated Domains capability for the bundle ID, and the entitlements must also includecom.apple.application-identifierandcom.apple.developer.team-identifiermatching the profile.
These tips all guard against the same vague "not associated with domain" error:
- Sign with an Apple Development identity (
mac.type: developmentin electron-builder) and a macOS App Development profile that includes your Mac. Also install the profile on the machine (~/Library/Developer/Xcode/UserData/Provisioning Profiles/<UUID>.provisionprofile). - Copy
.appbundles withditto. Other copy methods can break the app seal, and macOS ignores the entitlements of an app whose signature failscodesign --verify --deep --strict. swcdregisters the domain association when the app launches. Check it withsudo swcutil show. If it gets stuck, runsudo swcutil resetand relaunch.- Prefer the default (production/CDN) association route.
?mode=developerwithsudo swcutil developer-mode -e truealso works, but it's often flaky.
Windows has no equivalent requirement. Linux has no native mode, so passkeys, including external security keys, only work in renderer mode there.
For help, visit our support page.
We're open to all community contributions! If you'd like to contribute in any way, please read our contribution guidelines and code of conduct.
@clerk/electron follows good practices of security, but 100% security cannot be assured.
@clerk/electron is provided "as is" without any warranty. Use at your own risk.
For more information and to report security issues, please refer to our security documentation.
This project is licensed under the MIT license.
See LICENSE for more information.