Skip to main content

Clerk Auth

How do you test authenticated flows without fighting the auth provider?

Clerk handles identity for Stackmates. The hardest part isn't configuring Clerk -- it's making tests work reliably against it. Bot detection, token ordering, session expiry. Get these wrong and tests fail silently with blank pages.

Testing Strategy

Three layers, each with a different job. Most teams build one and wonder why the other two break.

LayerWhat You TestClerk InvolvementSpeed
UnitComponents, hooks, server helpersMocked entirelyFast
IntegrationMiddleware, API routes, webhooksMocked entirelyFast
E2EFull sign-in/sign-out, protected flowsReal ClerkSlow

Rule: never hit Clerk's real API in unit/integration tests. Mock the SDK and control auth state in each test. Reserve real Clerk for E2E only.

Unit Testing

Mocking @clerk/nextjs

Create a shared mock that controls auth state per test.

// test/mocks/clerk-nextjs.ts
import type { SignedInAuthObject, SignedOutAuthObject } from "@clerk/nextjs/server";

let mockUser: any = null;
let mockAuth: SignedInAuthObject | SignedOutAuthObject = {
userId: null,
sessionId: null,
getToken: async () => null,
orgId: null,
orgRole: null,
orgSlug: null,
};

export const __setMockUser = (user: any) => {
mockUser = user;
mockAuth = user
? { ...mockAuth, userId: user.id, sessionId: "sess_123" }
: { ...mockAuth, userId: null, sessionId: null };
};

// Client hooks
export const useUser = () => ({
isLoaded: true,
isSignedIn: !!mockUser,
user: mockUser,
});

export const useAuth = () => ({
isLoaded: true,
isSignedIn: !!mockAuth.userId,
userId: mockAuth.userId,
sessionId: mockAuth.sessionId,
getToken: mockAuth.getToken,
});

// Server helpers (App Router)
export const auth = async () => mockAuth;
export const currentUser = async () => mockUser;

// Passthrough for ClerkProvider wrapper
export const ClerkProvider = ({ children }: any) => children;

Vitest Setup

// vitest.setup.ts
import { vi } from "vitest";

vi.mock("@clerk/nextjs", async () => {
return await import("./test/mocks/clerk-nextjs");
});

vi.mock("@clerk/nextjs/server", async () => {
const mod = await import("./test/mocks/clerk-nextjs");
return { auth: mod.auth, currentUser: mod.currentUser };
});

Jest Setup

// jest.setup.ts
jest.mock("@clerk/nextjs", () => require("./test/mocks/clerk-nextjs"));
jest.mock("@clerk/nextjs/server", () => {
const mod = require("./test/mocks/clerk-nextjs");
return { auth: mod.auth, currentUser: mod.currentUser };
});

Component Tests

import { __setMockUser } from '../test/mocks/clerk-nextjs';
import { render } from '@testing-library/react';
import { DashboardPage } from './DashboardPage';

beforeEach(() => __setMockUser(null)); // default: signed-out

it('shows login prompt when signed out', () => {
const { getByText } = render(<DashboardPage />);
expect(getByText('Sign in')).toBeInTheDocument();
});

it('renders dashboard for signed-in user', () => {
__setMockUser({
id: 'user_1',
emailAddresses: [{ emailAddress: 'test@example.com' }],
});
const { getByText } = render(<DashboardPage />);
expect(getByText('Welcome')).toBeInTheDocument();
});

Integration Testing

Middleware

Extract core logic into a testable function. Mock auth() at the boundary.

// middleware.ts
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";

const protectedRoutes = ["/dashboard", "/settings"];

export async function handleAuth(request: Request) {
const url = new URL(request.url);
const isProtected = protectedRoutes.some((p) => url.pathname.startsWith(p));
if (!isProtected) return NextResponse.next();

const { userId } = await auth();
if (!userId) {
const signInUrl = new URL("/sign-in", url.origin);
signInUrl.searchParams.set("redirect_url", url.pathname);
return NextResponse.redirect(signInUrl);
}
return NextResponse.next();
}
// middleware.test.ts
import { __setMockUser } from "../test/mocks/clerk-nextjs";
import { handleAuth } from "./middleware";

it("redirects anonymous user from protected route", async () => {
__setMockUser(null);
const req = new Request("https://example.com/dashboard");
const res = await handleAuth(req);
expect(res.status).toBe(307);
});

it("allows authenticated user through", async () => {
__setMockUser({ id: "user_1" });
const req = new Request("https://example.com/dashboard");
const res = await handleAuth(req);
expect(res.status).toBe(200);
});

Webhook Testing (Svix)

Clerk webhooks arrive via Svix with signature verification. Mock Svix in tests.

// Mock svix in test setup
vi.mock("svix", () => ({
Webhook: class {
constructor(_secret: string) {}
verify(payload: string) {
return JSON.parse(payload); // trust payload in tests
}
},
}));
// webhook.test.ts
import { POST } from "@/app/api/webhooks/clerk/route";

it("handles user.created webhook", async () => {
const body = JSON.stringify({
type: "user.created",
data: { id: "user_1", email_addresses: [{ email_address: "a@test.com" }] },
});

const req = new Request("http://localhost/api/webhooks/clerk", {
method: "POST",
body,
headers: {
"svix-id": "msg_test",
"svix-timestamp": String(Math.floor(Date.now() / 1000)),
"svix-signature": "v1_test",
},
});

const res = await POST(req);
expect(res.status).toBe(200);
});

For realistic integration tests, use the Svix SDK to generate real signed payloads against your webhook secret.

E2E Testing (Playwright)

The Critical Rule

Set the testing token BEFORE page navigation. Always.

// CORRECT -- token before navigation
test("protected page loads", async ({ page }) => {
await setupClerkTestingToken({ page });
await page.goto("/dashboard");
});

// WRONG -- Clerk blocks the page before the token arrives
test("protected page loads", async ({ page }) => {
await page.goto("/dashboard");
await setupClerkTestingToken({ page }); // too late
});

setupClerkTestingToken() intercepts Clerk's Frontend API requests. Navigate first and Clerk's bot detection fires before the token exists. Result: blank page, no error, silent failure.

Global Setup

Authenticate once, store state, reuse across tests.

e2e/
├── global.setup.ts # Authenticate, save state
├── tests/
│ ├── admin.spec.ts # Uses admin storageState
│ └── user.spec.ts # Uses user storageState
playwright/
└── .clerk/
├── admin.json # Stored auth state (gitignored)
└── user.json

global.setup.ts:

import { clerkSetup, clerk } from "@clerk/testing/playwright";
import { test as setup } from "@playwright/test";

setup.describe.configure({ mode: "serial" });

setup("global setup", async ({}) => {
await clerkSetup();
});

setup("authenticate", async ({ page }) => {
await page.goto("/");
await clerk.signIn({
page,
signInParams: {
strategy: "password",
identifier: process.env.E2E_CLERK_USER_USERNAME!,
password: process.env.E2E_CLERK_USER_PASSWORD!,
},
});
await page.context().storageState({ path: "playwright/.clerk/user.json" });
});

playwright.config.ts:

export default defineConfig({
projects: [
{ name: "setup", testMatch: /global\.setup\.ts/ },
{
name: "authenticated",
use: { storageState: "playwright/.clerk/user.json" },
dependencies: ["setup"],
},
{
name: "unauthenticated",
testMatch: /.*\.unauth\.spec\.ts/,
},
],
});

Multi-Role Testing

Separate storage states per role. Different browser contexts for simultaneous interaction.

test("admin and user interact", async ({ browser }) => {
const adminCtx = await browser.newContext({
storageState: "playwright/.clerk/admin.json",
});
const userCtx = await browser.newContext({
storageState: "playwright/.clerk/user.json",
});

const adminPage = await adminCtx.newPage();
const userPage = await userCtx.newPage();
// Both pages operate with different auth states
});

Test Helpers

@clerk/testing (v1.14.3+) exports from @clerk/testing/playwright:

HelperPurpose
clerkSetup()Global token setup -- run once, serial mode
setupClerkTestingToken({ page })Per-test token injection
clerk.signIn({ page, signInParams })Programmatic sign-in (calls setupClerkTestingToken internally)
clerk.signOut({ page })Sign out current user
clerk.loaded({ page })Wait for Clerk JS to initialize

Do not call both setupClerkTestingToken() and clerk.signIn(). signIn calls the token setup internally. Doubling up creates duplicate requests and flaky tests.

Environment Setup

VariablePurposeSecret?
CLERK_PUBLISHABLE_KEYIdentifies Clerk instance (pk_test_...)No
CLERK_SECRET_KEYBackend API auth (sk_test_...)Yes
E2E_CLERK_USER_USERNAMETest user credentialsYes
E2E_CLERK_USER_PASSWORDTest user credentialsYes

Dev Instance Test Mode

Development instances provide built-in shortcuts:

  • Test emails: any address with +clerk_test (e.g. jane+clerk_test@example.com) -- no email sent
  • Test phones: +1 (XXX) 555-0100 through +1 (XXX) 555-0199 -- no SMS sent
  • Verification code: 424242 for both email and phone

Instance Limits

AspectDevelopmentProduction
User cap100Unlimited
OAuthShared credentials (auto)Must provide your own
Monthly SMS20 (test numbers exempt)Per plan
Monthly email100 (test addresses exempt)Per plan
Testing tokensSupportedSupported (Aug 2025+)

Pitfalls

PitfallSymptomFix
Token after navigationBlank page, no errorMove setupClerkTestingToken() before page.goto()
Duplicate token setupFlaky auth, intermittent failuresUse clerk.signIn() OR setupClerkTestingToken(), not both
Session token expiryAuth failures after 60sUse stored storageState -- Clerk SDK auto-refreshes
CI storage stateWorks locally, fails in CImkdir -p playwright/.clerk before tests, add to .gitignore
Same user in parallelBot detection, account lockoutOne test user per worker shard, or serialize auth tests
Rapid sign-in attemptsTemporary blocks from ClerkUse long-lived test users, add small delays between auth actions
OTP in productionSign-in fails with code-basedUse email/password strategy for production testing
MFA flowsclerk.signIn() no MFA supportUse manual Playwright UI interaction for MFA
Dev user capCan't create more test usersClean up or use fixed set (max 100)
Mock internalsBrittle tests coupled to ClerkMock at boundary for unit tests, real Clerk for E2E only
No staging instanceConfig drift between environmentsCreate separate Clerk app, replicate config manually
Mass user creation/deletionLooks like abuse to ClerkKeep stable test users, don't create/delete on each run

Staging

Clerk has no native staging instance. Workaround: create a separate Clerk application with its own domain. Configuration does not sync between applications -- replicate manually.

For non-production environments, disable bot detection entirely in the Clerk Dashboard to eliminate the testing token requirement.

Clerk Testing Verdict

StrengthWeakness
E2E helpers work when used rightBot detection creates flaky CI
Testing tokens in productionNo staging instance, manual config sync
storageState reuse is fastUnit testing requires custom mocks
Dev instance shortcuts100 user cap in dev
Familiar OAuth/password flowsExternal dependency for every E2E run

The fundamental tension: Clerk optimizes for security (bot detection, rate limiting) which directly conflicts with test automation patterns (rapid, repeated, scripted logins). Unit/integration mocking eliminates this. E2E requires careful orchestration.

Compare with Better Auth (self-hosted, full DB control in tests) and zkLogin (on-chain identity, no auth provider dependency).

Context

Questions

How do you make auth testing as reliable as unit tests?

  • What's the minimum test surface that proves auth works without testing Clerk's internals?
  • When should you mock Clerk vs test against it directly?
  • How do you handle auth state across parallel test workers without race conditions?
  • At what point does the testing friction of a managed service outweigh the setup cost of self-hosting?