Skip to main content

How to Test Clerk Authentication in Playwright

Short answer

Clerk-hosted sign-in UI is fast to ship but slow and brittle in every spec. Use @clerk/testing or Backend API to create per-run users, clerk.signIn for session bootstrap, and probe Assert on protected routes—reserve full hosted UI flows for a few smoke tests. Org switching and JWT templates need explicit scenarios so wrong orgId does not slip through green CI.

Part of Testing Guides by auth and identity.

Who this is for

Teams using Clerk for auth on Next.js App Router, React, or Remix who need Playwright E2E with organization switching, RBAC roles, and parallel CI—without logging through Clerk's hosted components on every test.

Typical stacks: Next.js + @clerk/nextjs, Clerk Organizations, Clerk + Supabase JWT templates, or B2B SaaS with org-scoped data.

Why Clerk needs different Arrange/Assert

LayerPitfallE2E fix
Hosted UIiframe / shadow DOM drift@clerk/testing sign-in
Session JWTStale after role changeRe-sign-in or getToken({ skipCache: true })
OrganizationsActive org wrongclerk.setActive({ organization })
Parallel CIShared test@ userBackend API create user per runId
Middlewareauth() null in RSCStorage state from testing helper
WebhooksUser created asyncPoll probe profile row

Client useUser() showing signed-in is not proof your API validates Clerk session on server routes.

Complexity map

ScenarioEdge caseWhy tests breakApproach
@clerk/testing setupMissing CLERK_TESTING_TOKENSign-in failsExport from Clerk Dashboard
Per-run userEmail collision422 on createuser+${runId}@test.local
Org switchData from previous orgCross-tenant leaksetActive + probe org scope
Role changeJWT cachedOld role in APIForce token refresh
Invitation acceptToken in emailCI inboxAPI create membership
OAuth socialPopup in headlessHangTesting helper or split tier
MFA enabledTOTP on test userBlocked automationTest user without MFA in CI
Sign-outStorage state persistsNext test logged inFresh context per test
Backend API rateMass user create429Reuse worker-scoped user
authorizedPartiesWrong domain cookieSession invalidMatch PLAYWRIGHT_BASE_URL
ImpersonationAdmin featureAudit gapProbe impersonation log
Deleted userStill in storageStateFalse positiveRegenerate state
Multi-tabOrg switch tab A onlyTab B staleReload after setActive
JWT template claimsSupabase RLS mismatchEmpty dataProbe with template claims
Satellite domainsCross-domain sessionUntestedDocument staging URLs

Setup: @clerk/testing

Official Playwright integration:

npm install -D @clerk/testing
// playwright.config.ts
import { clerkSetup } from '@clerk/testing/playwright';

export default defineConfig({
globalSetup: require.resolve('./global-setup.ts'),
// ...
});
// global-setup.ts
import { clerkSetup } from '@clerk/testing/playwright';

export default async function globalSetup() {
await clerkSetup();
}

Set CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, and CLERK_TESTING_TOKEN from Clerk testing docs.

Per-run user via Backend API

// tests/helpers/clerk-users.ts
import { clerkClient } from '@clerk/clerk-sdk-node';

export async function createRunUser(runId: string) {
const email = `e2e+${runId}@test.local`;
const user = await clerkClient.users.createUser({
emailAddress: [email],
password: `pw-${runId}`,
skipPasswordChecks: true,
});
return { userId: user.id, email, password: `pw-${runId}` };
}

Tear down in afterEach or nightly job—Clerk test instances accumulate users.

Sign in without hosted UI clicks

// @Scenario: auth/clerk-org-member
import { test, expect } from '@playwright/test';
import { clerk } from '@clerk/testing/playwright';

test('org member sees org-scoped invoice', async ({ page, runId }) => {
const { email, password } = await createRunUser(runId);
const org = await createRunOrg(runId, email);

await page.goto('/');
await clerk.signIn({ page, signInParams: { strategy: 'password', identifier: email, password } });
await clerk.setActive({ page, organization: org.id });

await page.goto('/invoices');
await expect(page.getByRole('heading', { name: 'Invoices' })).toBeVisible();

await expect.poll(async () => {
const res = await page.request.get(`/api/test/probe-invoices?runId=${runId}`);
return (await res.json()).orgId;
}).toBe(org.id);
});

Use hosted UI smoke tests separately—one spec per release train.

Org switching scenario

test('switching org changes visible project count', async ({ page, runId }) => {
const user = await createRunUser(runId);
const orgA = await createRunOrg(`${runId}-a`, user.email);
const orgB = await createRunOrg(`${runId}-b`, user.email);
await seedProjects(orgA.id, { count: 2, runId });
await seedProjects(orgB.id, { count: 5, runId });

await clerk.signIn({ page, signInParams: { strategy: 'password', identifier: user.email, password: user.password } });
await clerk.setActive({ page, organization: orgA.id });
await page.goto('/projects');
await expect.poll(() => probeProjectCount(page, runId, orgA.id)).toBe(2);

await clerk.setActive({ page, organization: orgB.id });
await page.reload();
await expect.poll(() => probeProjectCount(page, runId, orgB.id)).toBe(5);
});

Next.js middleware integration

Ensure tests hit the same domain Clerk expects:

// middleware.ts — clerkMiddleware protects routes
import { clerkMiddleware } from '@clerk/nextjs/server';

export default clerkMiddleware();

Playwright baseURL must match Clerk authorized parties for session cookies to stick.

Probe Assert on protected API

await expect.poll(async () => {
const res = await page.request.get('/api/protected/resource');
return res.status();
}).toBe(200);

// Negative: signed out
await clerk.signOut({ page });
await expect(page.request.get('/api/protected/resource')).resolves.toHaveProperty('status', 401);

Anti-patterns

Anti-patternWhy it failsBetter approach
Click Clerk UI every specSlow, flaky iframesclerk.signIn
Shared staging userOrg data collisionPer-run Backend API user
Assert useUser() onlyClient state liesProbe API with session
Skip org switch testsCross-tenant bugssetActive matrix
Production Clerk keys in CIUser pollutionDedicated test instance
No sign-out between testsState leaksignOut or fresh context

External references

Example scenario

Situation: User belongs to org A and org B; active org still A while navigating to org B invoice URL.

Expected outcome: 403 or empty state; probe shows zero invoices for wrong org context.

Why UI-only automation breaks: UI hides nav link but direct URL still returns data—probe leaks JSON.

  1. Arrange: Seed user in two orgs with runId-scoped invoices via test API.
  2. Act: Sign in; setActive org A; navigate to org B invoice deep link.
  3. Assert: Probe invoice count for active org only; API returns 403 for cross-org id.

TestChimp workflow: // @Scenario: auth/clerk-org-isolation; /testchimp test repairs specs when Clerk component regen changes selectors.

Same Arrange/Act/Assert pattern as expired-coupon checkout.

Frequently asked questions

Should I use @clerk/testing or click the Clerk sign-in UI?

Use @clerk/testing clerk.signIn for most specs—fast and stable. Keep a small set of hosted UI smoke tests to catch component integration regressions.

How do I create per-run Clerk users in CI?

Clerk Backend API createUser with email e2e+runId@test.local. Never share one staging user across parallel workers.

How do I test organization switching?

After signIn, call clerk.setActive with organization id, reload if needed, and probe org-scoped data—not just visible nav labels.

What is CLERK_TESTING_TOKEN?

Short-lived token from Clerk Dashboard enabling testing helpers. Configure in CI secrets alongside publishable and secret keys for your test instance.

Clerk + Supabase—how do tests assert RLS?

Ensure JWT template claims match seed data org_id. Probe Supabase rows as the signed-in user via server route—see Supabase guide.

Can I reuse storageState across tests?

Yes for read-only suites; org-switch and role-change specs need fresh sign-in or setActive to avoid stale JWT claims.

How do I test invited org members?

Backend API create organization membership or accept invite via test token route. Avoid real email unless using inbox capture.

TestChimp with Clerk apps?

/testchimp init wires probe routes for org-scoped data; /testchimp test maintains Clerk specs when auth UI or middleware matchers change on agent PRs.

Apply these patterns in your repo

Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.

Start free on TestChimp · Book a demo