diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b6e639c157e..630ed662984 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -347,6 +347,7 @@ jobs:
"custom",
"hono",
"chrome-extension",
+ "electron",
]
test-project: ["chrome"]
include:
@@ -486,10 +487,24 @@ jobs:
working-directory: ./integration/certs
run: ls -la && pwd
+ - name: Ensure Xvfb is installed
+ if: ${{ matrix.test-name == 'electron' }}
+ run: |
+ if ! command -v xvfb-run &> /dev/null; then
+ sudo apt-get update
+ sudo apt-get install -y xvfb
+ fi
+ xvfb-run --help > /dev/null
+
- name: Run Integration Tests
id: integration-tests
timeout-minutes: 25
- run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
+ run: |
+ if [ "${{ matrix.test-name }}" = "electron" ]; then
+ xvfb-run -a pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
+ else
+ pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
+ fi
env:
E2E_DEBUG: "1"
E2E_APP_CLERK_JS_DIR: ${{runner.temp}}
diff --git a/integration/presets/electron.ts b/integration/presets/electron.ts
new file mode 100644
index 00000000000..78bbe9a5132
--- /dev/null
+++ b/integration/presets/electron.ts
@@ -0,0 +1,17 @@
+import { applicationConfig } from '../models/applicationConfig';
+import { templates } from '../templates';
+import { PKGLAB } from './utils';
+
+const vite = applicationConfig()
+ .setName('electron-vite')
+ .useTemplate(templates['electron-vite'])
+ .setEnvFormatter('public', key => `VITE_${key}`)
+ .addScript('setup', 'pnpm install')
+ .addScript('dev', 'pnpm build')
+ .addScript('build', 'pnpm build')
+ .addScript('serve', 'echo noop')
+ .addDependency('@clerk/electron', PKGLAB);
+
+export const electron = {
+ vite,
+} as const;
diff --git a/integration/presets/index.ts b/integration/presets/index.ts
index f67f3b36385..de6cacfcb03 100644
--- a/integration/presets/index.ts
+++ b/integration/presets/index.ts
@@ -1,6 +1,7 @@
import { astro } from './astro';
import { chromeExtension } from './chrome-extension';
import { customFlows } from './custom-flows';
+import { electron } from './electron';
import { envs, instanceKeys } from './envs';
import { expo } from './expo';
import { express } from './express';
@@ -17,6 +18,7 @@ import { vue } from './vue';
export const appConfigs = {
chromeExtension,
customFlows,
+ electron,
envs,
express,
fastify,
diff --git a/integration/templates/electron-vite/index.html b/integration/templates/electron-vite/index.html
new file mode 100644
index 00000000000..bdb9021af6b
--- /dev/null
+++ b/integration/templates/electron-vite/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ Clerk Electron Integration
+
+
+
+
+
+
diff --git a/integration/templates/electron-vite/main.mjs b/integration/templates/electron-vite/main.mjs
new file mode 100644
index 00000000000..360b380f292
--- /dev/null
+++ b/integration/templates/electron-vite/main.mjs
@@ -0,0 +1,98 @@
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+import { setupMain } from '@clerk/electron';
+import { storage } from '@clerk/electron/storage';
+import { app, BrowserWindow, net, protocol } from 'electron';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const RENDERER_SCHEME = 'clerk';
+const RENDERER_HOST = 'app';
+// electron-vite sets this in dev. Its presence is how we pick proxy vs. file serving.
+const DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL;
+const rendererRoot = path.resolve(__dirname, 'dist');
+
+const clerk = setupMain({
+ storage: storage(),
+ renderer: {
+ scheme: RENDERER_SCHEME,
+ host: RENDERER_HOST,
+ },
+});
+
+async function createWindow() {
+ const win = new BrowserWindow({
+ width: 1000,
+ height: 800,
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ preload: path.resolve(__dirname, 'preload.mjs'),
+ sandbox: false,
+ },
+ });
+
+ await win.loadURL(`${RENDERER_SCHEME}://${RENDERER_HOST}/`);
+}
+
+function registerClerkAppProtocol() {
+ protocol.handle(RENDERER_SCHEME, async request => {
+ const url = new URL(request.url);
+
+ // Only the app host serves the UI. Other hosts can be reserved for deep links.
+ if (url.host !== RENDERER_HOST) {
+ return new Response('Not found', { status: 404 });
+ }
+
+ // Dev: proxy to Vite so the renderer always runs from clerk://app.
+ if (DEV_SERVER_URL) {
+ const target = new URL(url.pathname + url.search, DEV_SERVER_URL);
+ // Intentionally do not forward the renderer's request headers to localhost.
+ const init = { method: request.method };
+
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
+ init.body = request.body;
+ init.duplex = 'half';
+ }
+
+ try {
+ return await net.fetch(target.toString(), init);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error('[clerk-app-protocol] dev proxy failed', {
+ requested: request.url,
+ target: target.toString(),
+ devServer: DEV_SERVER_URL,
+ error: message,
+ });
+ return new Response(`clerk://app dev proxy error: ${message}`, { status: 502 });
+ }
+ }
+
+ // Prod: serve the bundled renderer with a traversal guard and SPA fallback.
+ const requestedPath = decodeURIComponent(url.pathname);
+ const resolvedPath = path.resolve(rendererRoot, `.${requestedPath}`);
+
+ if (resolvedPath !== rendererRoot && !resolvedPath.startsWith(rendererRoot + path.sep)) {
+ return new Response(null, { status: 403 });
+ }
+
+ const hasExtension = /\.[^/]+$/.test(url.pathname);
+ const filePath = hasExtension ? resolvedPath : path.join(rendererRoot, 'index.html');
+
+ return net.fetch(pathToFileURL(filePath).toString());
+ });
+}
+
+app.whenReady().then(async () => {
+ registerClerkAppProtocol();
+ await createWindow();
+});
+
+app.on('window-all-closed', () => {
+ app.quit();
+});
+
+app.on('before-quit', () => {
+ clerk.cleanup();
+});
diff --git a/integration/templates/electron-vite/package.json b/integration/templates/electron-vite/package.json
new file mode 100644
index 00000000000..55a1393117f
--- /dev/null
+++ b/integration/templates/electron-vite/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "electron-vite",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build"
+ },
+ "dependencies": {
+ "electron-store": "^8.2.0",
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.12.0",
+ "@types/react": "18.3.12",
+ "@types/react-dom": "18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "electron": "^39.2.6",
+ "typescript": "^5.7.3",
+ "vite": "^7.3.3"
+ },
+ "engines": {
+ "node": ">=22.11.0"
+ }
+}
diff --git a/integration/templates/electron-vite/preload.mjs b/integration/templates/electron-vite/preload.mjs
new file mode 100644
index 00000000000..718064bfd3c
--- /dev/null
+++ b/integration/templates/electron-vite/preload.mjs
@@ -0,0 +1,3 @@
+import { setupPreload } from '@clerk/electron/preload';
+
+setupPreload();
diff --git a/integration/templates/electron-vite/src/main.tsx b/integration/templates/electron-vite/src/main.tsx
new file mode 100644
index 00000000000..bcd7ca66d73
--- /dev/null
+++ b/integration/templates/electron-vite/src/main.tsx
@@ -0,0 +1,44 @@
+import { ClerkProvider, Show, SignIn, UserButton, useAuth } from '@clerk/electron/react';
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+
+import './style.css';
+
+const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string;
+
+function App() {
+ return (
+ {}}
+ routerReplace={() => {}}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function AuthInfo() {
+ const { sessionId, userId } = useAuth();
+
+ return (
+
+
{userId}
+
{sessionId}
+
+ );
+}
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/integration/templates/electron-vite/src/style.css b/integration/templates/electron-vite/src/style.css
new file mode 100644
index 00000000000..db4a37bd4ba
--- /dev/null
+++ b/integration/templates/electron-vite/src/style.css
@@ -0,0 +1,20 @@
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ font-family:
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ background: #f8fafc;
+}
+
+main {
+ display: grid;
+ min-height: 100vh;
+ place-items: center;
+}
diff --git a/integration/templates/electron-vite/src/vite-env.d.ts b/integration/templates/electron-vite/src/vite-env.d.ts
new file mode 100644
index 00000000000..11f02fe2a00
--- /dev/null
+++ b/integration/templates/electron-vite/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/integration/templates/electron-vite/tsconfig.json b/integration/templates/electron-vite/tsconfig.json
new file mode 100644
index 00000000000..f0b7f75c359
--- /dev/null
+++ b/integration/templates/electron-vite/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "allowSyntheticDefaultImports": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "lib": ["DOM", "DOM.Iterable", "ES2020"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "noEmit": true,
+ "strict": true,
+ "target": "ES2020",
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/integration/templates/electron-vite/vite.config.ts b/integration/templates/electron-vite/vite.config.ts
new file mode 100644
index 00000000000..fabde1a8f5e
--- /dev/null
+++ b/integration/templates/electron-vite/vite.config.ts
@@ -0,0 +1,6 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [react()],
+});
diff --git a/integration/templates/index.ts b/integration/templates/index.ts
index 5588158e1f5..2fd05daa6ae 100644
--- a/integration/templates/index.ts
+++ b/integration/templates/index.ts
@@ -24,6 +24,7 @@ export const templates = {
'react-router-library': resolve(__dirname, './react-router-library'),
'custom-flows-react-vite': resolve(__dirname, './custom-flows-react-vite'),
'chrome-extension-vite': resolve(__dirname, './chrome-extension-vite'),
+ 'electron-vite': resolve(__dirname, './electron-vite'),
} as const;
if (new Set([...Object.values(templates)]).size !== Object.values(templates).length) {
diff --git a/integration/tests/electron/basic.test.ts b/integration/tests/electron/basic.test.ts
new file mode 100644
index 00000000000..9c838b982f2
--- /dev/null
+++ b/integration/tests/electron/basic.test.ts
@@ -0,0 +1,35 @@
+import { createPageObjects } from '@clerk/testing/playwright/unstable';
+
+import type { FakeUser } from '../../testUtils';
+import { createTestUtils } from '../../testUtils';
+import { expect, test } from './fixtures';
+
+test.describe('electron basic auth @electron', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async ({ testApp }) => {
+ const u = createTestUtils({ app: testApp });
+ fakeUser = u.services.users.createFakeUser();
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser?.deleteIfExists();
+ });
+
+ test('signs in with email and password', async ({ electronPage }) => {
+ const { signIn } = createPageObjects({ page: electronPage, useTestingToken: false });
+
+ await signIn.waitForMounted();
+ await expect(electronPage.locator('.cl-signIn-root')).toBeVisible();
+
+ await signIn.setIdentifier(fakeUser.email);
+ await signIn.continue();
+ await signIn.setPassword(fakeUser.password);
+ await signIn.continue();
+
+ await expect(electronPage.locator('[data-testid="user-id"]')).toHaveText(/^user_/, { timeout: 30_000 });
+ });
+});
diff --git a/integration/tests/electron/fixtures.ts b/integration/tests/electron/fixtures.ts
new file mode 100644
index 00000000000..acad90d5e8a
--- /dev/null
+++ b/integration/tests/electron/fixtures.ts
@@ -0,0 +1,62 @@
+import * as path from 'node:path';
+
+import { setupClerkTestingToken } from '@clerk/testing/playwright';
+import { _electron as electron, test as base } from '@playwright/test';
+import type { ElectronApplication, Page } from '@playwright/test';
+
+import type { Application } from '../../models/application';
+import { appConfigs } from '../../presets';
+import { run } from '../../scripts';
+import { setupClerkTestingEnv } from '../chrome-extension/helpers';
+
+type WorkerFixtures = {
+ testApp: Application;
+};
+
+type TestFixtures = {
+ electronApplication: ElectronApplication;
+ electronPage: Page;
+};
+
+const electronExecutable = (app: Application) =>
+ path.resolve(app.appDir, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron');
+
+export const test = base.extend({
+ testApp: [
+ async ({}, use) => {
+ const env = appConfigs.envs.withEmailCodes;
+ const app = await appConfigs.electron.vite.commit();
+
+ await app.withEnv(env);
+ await app.setup();
+ // pkglab installs with scripts disabled, so install Electron's binary explicitly.
+ await run('node node_modules/electron/install.js', { cwd: app.appDir });
+ await app.build();
+ await setupClerkTestingEnv(env);
+
+ await use(app);
+ await app.teardown();
+ },
+ { scope: 'worker', timeout: 120_000 },
+ ],
+
+ electronApplication: async ({ testApp }, use) => {
+ const application = await electron.launch({
+ args: [path.resolve(testApp.appDir, 'main.mjs')],
+ cwd: testApp.appDir,
+ executablePath: electronExecutable(testApp),
+ });
+
+ await use(application);
+ await application.close();
+ },
+
+ electronPage: async ({ electronApplication }, use) => {
+ const page = await electronApplication.firstWindow();
+ await setupClerkTestingToken({ context: page.context() });
+ await page.reload();
+ await use(page);
+ },
+});
+
+export { expect } from '@playwright/test';
diff --git a/package.json b/package.json
index d49b3668f42..94790c2f874 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"test:integration:cleanup": "pnpm playwright test --config integration/playwright.cleanup.config.ts",
"test:integration:custom": "pnpm test:integration:base --grep @custom",
"test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts",
+ "test:integration:electron": "pnpm test:integration:base --grep @electron",
"test:integration:expo-web:disabled": "E2E_APP_ID=expo.expo-web pnpm test:integration:base --grep @expo-web",
"test:integration:express": "E2E_APP_ID=express.* pnpm test:integration:base --grep @express",
"test:integration:fastify": "E2E_APP_ID=fastify.* pnpm test:integration:base --grep @fastify",
diff --git a/turbo.json b/turbo.json
index 42298cbc8f2..1766fb9c531 100644
--- a/turbo.json
+++ b/turbo.json
@@ -363,6 +363,13 @@
"outputs": ["integration/playwright-report/**"],
"outputLogs": "new-only"
},
+ "//#test:integration:electron": {
+ "dependsOn": ["@clerk/electron#build"],
+ "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS", "INTEGRATION_STAGING_INSTANCE_KEYS"],
+ "inputs": ["integration/**", "packages/electron/**"],
+ "outputs": ["integration/playwright-report/**"],
+ "outputLogs": "new-only"
+ },
"//#typedoc:generate": {
"dependsOn": ["@clerk/nextjs#build", "@clerk/react#build", "@clerk/shared#build"],
"inputs": ["tsconfig.typedoc.json", "typedoc.config.mjs", ".typedoc/**/*.mjs"],