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
Global Setup (recommended)
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:
| Helper | Purpose |
|---|---|
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
| Variable | Purpose | Secret? |
|---|---|---|
CLERK_PUBLISHABLE_KEY | Identifies Clerk instance (pk_test_...) | No |
CLERK_SECRET_KEY | Backend API auth (sk_test_...) | Yes |
E2E_CLERK_USER_USERNAME | Test user credentials | Yes |
E2E_CLERK_USER_PASSWORD | Test user credentials | Yes |
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-0100through+1 (XXX) 555-0199— no SMS sent - Verification code:
424242for both email and phone
Instance Limits
| Aspect | Development | Production |
|---|---|---|
| User cap | 100 | Unlimited |
| OAuth | Shared credentials (auto) | Must provide your own |
| Monthly SMS | 20 (test numbers exempt) | Per plan |
| Monthly email | 100 (test addresses exempt) | Per plan |
| Testing tokens | Supported | Supported (Aug 2025+) |
Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Token after navigation | Blank page, no error | Move setupClerkTestingToken() before page.goto() |
| Duplicate token setup | Flaky auth, intermittent failures | Use clerk.signIn() OR setupClerkTestingToken(), not both |
| Session token expiry | Auth failures mid-suite after 60s | Use stored storageState — Clerk SDK auto-refreshes |
| CI storage state | Works locally, fails in GitHub Actions | mkdir -p playwright/.clerk before tests, add to .gitignore |
| OTP in production | Sign-in fails with code-based auth | Use email/password strategy for production testing |
| MFA flows | clerk.signIn() doesn't support MFA | Use manual Playwright UI interaction for MFA |
| Dev user cap | Can't create more test users | Clean up or use fixed set of test users (max 100) |
| Mock internals | Brittle tests coupled to Clerk | Mock @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
- Identity and Security — The identity problem space
- Trust Architecture — Trust infrastructure patterns
- Product Engineering — Engineering standards
Links
- Clerk — Testing with Playwright — Official setup guide
- Clerk — Test Authenticated Flows — StorageState pattern
- Clerk — Test Helpers Reference — API reference
- Clerk — Test Emails and Phones — Dev instance test mode
- Clerk — Production Testing Tokens — Aug 2025 changelog
- clerk-playwright-nextjs — Official example repo
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?