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 E2E tests work reliably against it. Bot detection, token ordering, session expiry. Get these wrong and tests fail silently with blank pages.

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 and attaches __clerk_testing_token. Navigate first and Clerk's bot detection fires before the token exists. Result: blank page, no error message, silent failure.

Test Architecture

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. Use 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 token 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 test 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 mid-suite after 60sUse stored storageState — Clerk SDK auto-refreshes
CI storage stateWorks locally, fails in GitHub Actionsmkdir -p playwright/.clerk before tests, add to .gitignore
OTP in productionSign-in fails with code-based authUse email/password strategy for production testing
MFA flowsclerk.signIn() doesn't support MFAUse manual Playwright UI interaction for MFA
Dev user capCan't create more test usersClean up or use fixed set of test users (max 100)
Mock internalsBrittle tests coupled to ClerkMock @clerk/nextjs at boundary for unit tests, real Clerk for E2E only

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.

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?