Authentication Testing Guide

How to Test Clerk Sign-In with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing Clerk authentication with Playwright. Email and verification code login, OAuth social connections, phone SMS verification, Testing Tokens for CI, session management, and the component rendering pitfalls that break real suites.

200K+

Clerk powers authentication for over 200,000 applications, handling millions of sign-ins daily through pre-built components that abstract away the complexity of modern auth flows.

0Auth Methods Covered
0Test Scenarios
0%Fewer Lines with Assrt
0sTesting Token TTL for CI

1. Why Testing Clerk Is Different

Clerk is not a simple login form you control. When you drop <SignIn /> or <SignUp />into your React app, Clerk renders a pre-built component that manages its own DOM, handles multi-step verification flows, and communicates with Clerk's backend API on your behalf. That abstraction is powerful for shipping quickly, but it creates three structural challenges for end-to-end testing.

First, Clerk components manage their own internal state and rendering lifecycle. The input fields, buttons, and error messages are rendered by Clerk's JavaScript, not your application code. Selectors that target your app's DOM tree may not reach the elements you need. Second, authentication flows are inherently multi-step. An email login requires entering the email, receiving a verification code, and submitting that code before a session is created. Each step involves a network round-trip to Clerk's API, and timing issues are common in CI environments. Third, OAuth flows redirect through Clerk's own domain (clerk.{app}.com) before reaching the identity provider and returning to your app. That means your test crosses at least three origins in a single sign-in.

Clerk recognized these testing challenges and introduced Testing Tokens, an official mechanism for bypassing the UI entirely in CI. This guide covers both approaches: driving the actual Clerk components with Playwright for visual regression confidence, and using Testing Tokens for fast, reliable CI pipelines.

Clerk Email + Code Sign-In Flow

🌐

User opens sign-in

📧

Enters email address

⚙️

Clerk sends verification code

📧

User retrieves code

🔒

Submits code

Session created

Clerk Sign-In Sequence (Email + Verification Code)

BrowserYour AppClerk APIEmailDashboardGET /sign-inRender <SignIn />POST email addressSend verification codeUser retrieves codePOST verification codeSession token (verified)Redirect to /dashboard

2. Setting Up a Reliable Test Environment

Before writing any test, configure a dedicated Clerk test instance and the tooling around it. Clerk provides separate development and production instances. For end-to-end testing, use the development instance, which gives you access to Testing Tokens and relaxed rate limits.

Environment Variables

.env.test

Creating Test Users via the Backend API

Do not create test users manually through the Clerk dashboard. Instead, provision them programmatically in your test setup using the Clerk Backend API. This ensures each test run starts with known state and makes cleanup deterministic.

test/setup/clerk-test-users.ts

Enabling Testing Tokens

Testing Tokens let you authenticate without going through the Clerk UI at all. To enable them, go to the Clerk dashboard, select your development instance, navigate to API Keys, and generate a Testing Token. This token is passed as a header in your test requests to create authenticated sessions programmatically.

playwright.config.ts

Clerk Test Instance Setup Checklist

  • Create a dedicated Clerk development instance for E2E testing
  • Generate a CLERK_SECRET_KEY and NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
  • Enable Testing Tokens in the Clerk dashboard under API Keys
  • Provision test users programmatically via the Clerk Backend API
  • Set all credentials in .env.test (never commit secrets to git)
  • Configure Playwright globalSetup to create and clean up test users
  • Verify the dev instance allows the 424242 test verification code

Test Environment Setup Flow

⚙️

Configure dev instance

🔒

Generate Testing Token

📧

Create test users via API

⚙️

Set env variables

Run Playwright tests

3. Scenario: Email + Verification Code Login

1

Email and Verification Code Login

Moderate

The most common Clerk sign-in flow asks the user for their email address, sends a one-time verification code, and then asks the user to enter that code. Testing this end-to-end requires a way to retrieve the verification code programmatically. In development mode, Clerk provides a predictable code or you can retrieve it via the Backend API.

Goal

Sign in with an existing test user using the email and verification code flow, land on the authenticated dashboard, and confirm the session is active.

Playwright vs Assrt

Email + Verification Code Sign-In

import { test, expect } from '@playwright/test';
import { clerkClient } from '@clerk/clerk-sdk-node';

test('email + verification code sign-in', async ({ page }) => {
  const testEmail = process.env.TEST_USER_EMAIL!;

  // 1. Navigate to sign-in page
  await page.goto('/sign-in');

  // 2. Clerk renders its own component. Wait for the email input.
  const emailInput = page.getByLabel('Email address');
  await expect(emailInput).toBeVisible({ timeout: 10_000 });
  await emailInput.fill(testEmail);

  // 3. Click continue to trigger the verification code
  await page.getByRole('button', { name: /continue/i }).click();

  // 4. Wait for the code input to appear
  const codeInput = page.getByLabel('Enter verification code');
  await expect(codeInput).toBeVisible({ timeout: 10_000 });

  // 5. Use the dev mode test code
  const code = '424242';
  await codeInput.fill(code);

  // 6. Click the verify button
  await page.getByRole('button', { name: /verify/i }).click();

  // 7. Assert we are redirected to the dashboard
  await page.waitForURL(/\/dashboard/, { timeout: 15_000 });
  await expect(page.getByText(/welcome/i)).toBeVisible();
});
57% fewer lines

4. Scenario: OAuth Through Clerk (Google)

2

Google OAuth Social Connection

Complex

Clerk's OAuth flow is a three-hop redirect. Your app sends the user to clerk.{app}.com, which redirects to accounts.google.com, which redirects back to Clerk, which finally redirects back to your app. Each hop is a full page navigation across a different origin. Playwright handles this natively, but the timing and the Google consent screen make this the hardest scenario to stabilize.

Why OAuth Tests Are Fragile

Google's consent screen is not under your control. It changes layout, adds CAPTCHAs for suspicious login patterns, and blocks automated browsers when it detects Playwright. For CI, the recommended approach is to use Clerk Testing Tokens (Section 7) to bypass OAuth entirely. For local development and visual regression testing, the approach below works reliably with a dedicated Google test account.

Playwright Implementation

import { test, expect } from '@playwright/test';

test('OAuth sign-in via Google', async ({ page }) => {
  // 1. Navigate to sign-in and click the Google button
  await page.goto('/sign-in');
  await page.getByRole('button', { name: /continue with google/i }).click();

  // 2. Wait for the redirect to Google's consent screen.
  //    This crosses through clerk.yourapp.com first.
  await page.waitForURL(/accounts\.google\.com/, { timeout: 15_000 });

  // 3. Fill Google credentials
  await page.getByLabel('Email or phone').fill(process.env.GOOGLE_TEST_EMAIL!);
  await page.getByRole('button', { name: /next/i }).click();

  await page.getByLabel('Enter your password').fill(
    process.env.GOOGLE_TEST_PASSWORD!
  );
  await page.getByRole('button', { name: /next/i }).click();

  // 4. Handle the consent/allow screen if it appears
  const allowButton = page.getByRole('button', { name: /allow|continue/i });
  if (await allowButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
    await allowButton.click();
  }

  // 5. Wait for the full redirect chain to complete
  //    Google -> Clerk -> Your app
  await page.waitForURL(/\/dashboard/, { timeout: 30_000 });

  // 6. Assert authenticated state
  await expect(page.getByText(/welcome/i)).toBeVisible();
});

Assrt Equivalent

# scenarios/clerk-google-oauth.assrt
describe: Sign in through Google OAuth via Clerk

given:
  - I am on the sign-in page
  - I have valid Google test credentials

steps:
  - click "Continue with Google"
  - wait for Google's login page to load
  - fill in the Google email and password
  - click through the consent screen if it appears
  - wait for the redirect back to my app

expect:
  - I am on /dashboard within 30 seconds
  - the page shows a welcome message
  - the user profile shows the Google email

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: Phone Number + SMS Code Sign-In

3

Phone Number and SMS Verification

Moderate

Clerk supports phone number sign-in where the user enters their phone number and receives an SMS with a verification code. This flow is similar to email verification, but retrieving the SMS code in a test environment requires a different strategy. In Clerk's development mode, you can use a predictable test code or retrieve it through the Backend API.

Playwright Implementation

import { test, expect } from '@playwright/test';

test('phone number + SMS code sign-in', async ({ page }) => {
  const testPhone = process.env.TEST_USER_PHONE!;

  // 1. Navigate to sign-in
  await page.goto('/sign-in');

  // 2. Switch to phone number sign-in if email is default
  const phoneTab = page.getByRole('button', { name: /phone/i });
  if (await phoneTab.isVisible({ timeout: 3_000 }).catch(() => false)) {
    await phoneTab.click();
  }

  // 3. Enter phone number
  const phoneInput = page.getByLabel(/phone number/i);
  await expect(phoneInput).toBeVisible({ timeout: 10_000 });
  await phoneInput.fill(testPhone);

  // 4. Click continue to trigger SMS
  await page.getByRole('button', { name: /continue/i }).click();

  // 5. Wait for code input
  const codeInput = page.getByLabel(/verification code|enter code/i);
  await expect(codeInput).toBeVisible({ timeout: 10_000 });

  // 6. In dev mode, Clerk uses a predictable test code
  //    For production-like testing, use an SMS receiving service
  const smsCode = '424242';
  await codeInput.fill(smsCode);

  // 7. Submit the code
  await page.getByRole('button', { name: /verify|continue/i }).click();

  // 8. Assert authenticated state
  await page.waitForURL(/\/dashboard/, { timeout: 15_000 });
  await expect(page.getByText(/welcome/i)).toBeVisible();
});

Assrt Equivalent

# scenarios/clerk-phone-sms-login.assrt
describe: Sign in with phone number and SMS verification code

given:
  - I am on the sign-in page
  - a test user exists with a known phone number

steps:
  - switch to phone number sign-in if needed
  - fill the phone number field with the test phone number
  - click "Continue"
  - wait for the verification code input
  - fill the code with 424242
  - click "Verify"

expect:
  - I am redirected to /dashboard within 15 seconds
  - the page shows a welcome message

6. Scenario: Sign-Up with Email Verification

4

New User Sign-Up with Email Verification

Moderate

The sign-up flow is distinct from sign-in because it creates a new user and often requires email verification before the account is active. Clerk's <SignUp /> component handles collecting the email, password (if configured), and any additional fields you have required. After submission, Clerk sends a verification email, and the user must enter the code to complete registration.

Playwright Implementation

import { test, expect } from '@playwright/test';
import { clerkClient } from '@clerk/clerk-sdk-node';

test('new user sign-up with email verification', async ({ page }) => {
  const uniqueEmail = `signup+${Date.now()}@yourapp.com`;

  // 1. Navigate to sign-up
  await page.goto('/sign-up');

  // 2. Fill in the sign-up form
  const emailInput = page.getByLabel('Email address');
  await expect(emailInput).toBeVisible({ timeout: 10_000 });
  await emailInput.fill(uniqueEmail);

  // Fill password if your instance requires it
  const passwordInput = page.getByLabel('Password');
  if (await passwordInput.isVisible({ timeout: 3_000 }).catch(() => false)) {
    await passwordInput.fill('SecureTestPass123!');
  }

  // Fill name fields if required
  const firstNameInput = page.getByLabel('First name');
  if (await firstNameInput.isVisible({ timeout: 2_000 }).catch(() => false)) {
    await firstNameInput.fill('Test');
    await page.getByLabel('Last name').fill('User');
  }

  // 3. Submit the form
  await page.getByRole('button', { name: /continue|sign up/i }).click();

  // 4. Wait for email verification step
  const codeInput = page.getByLabel(/verification code/i);
  await expect(codeInput).toBeVisible({ timeout: 10_000 });

  // 5. Enter the dev mode test code
  await codeInput.fill('424242');
  await page.getByRole('button', { name: /verify/i }).click();

  // 6. Assert the new user lands on the onboarding or dashboard page
  await page.waitForURL(/\/(dashboard|onboarding)/, { timeout: 15_000 });

  // 7. Clean up: delete the test user after the test
  const users = await clerkClient.users.getUserList({
    emailAddress: [uniqueEmail],
  });
  if (users.data.length > 0) {
    await clerkClient.users.deleteUser(users.data[0].id);
  }
});

Assrt Equivalent

# scenarios/clerk-signup-email.assrt
describe: New user sign-up with email verification

given:
  - I am on the sign-up page
  - I use a fresh random email address

steps:
  - fill the email address field
  - fill the password with "SecureTestPass123!"
  - fill first name with "Test" and last name with "User"
  - click "Continue"
  - wait for the verification code input
  - fill the code with 424242
  - click "Verify"

expect:
  - I am redirected to /dashboard or /onboarding within 15 seconds
  - the new user account is created in Clerk

7. Scenario: Using Testing Tokens to Bypass UI in CI

5

CI Authentication with Testing Tokens

Straightforward

Clerk Testing Tokens are the recommended approach for CI pipelines. Instead of driving the sign-in UI, you create an authenticated session programmatically by setting the Clerk session cookie before your test navigates to a protected page. This eliminates flakiness from component rendering timing, code retrieval, and OAuth redirects.

How Testing Tokens Work

You call the Clerk Backend API to create a sign-in token for a specific user. Then you use that token to set the session cookie in Playwright's browser context. When the browser navigates to your app, Clerk recognizes the session and treats the user as authenticated. No UI interaction is needed.

Playwright Implementation

import { test, expect } from '@playwright/test';

// Global setup: create an authenticated state file
// test/setup/clerk-auth.ts
import { clerkClient } from '@clerk/clerk-sdk-node';
import { chromium } from '@playwright/test';

async function globalSetup() {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  // 1. Create a sign-in token for the test user
  const signInToken = await clerkClient.signInTokens.createSignInToken({
    userId: process.env.TEST_USER_ID!,
    expiresInSeconds: 300,
  });

  // 2. Navigate to the sign-in token URL
  //    Clerk provides a URL that consumes the token and creates a session
  await page.goto(
    `${process.env.APP_BASE_URL}/sign-in#/factor-one?__clerk_ticket=${signInToken.token}`
  );

  // 3. Wait for the redirect to complete and session to be set
  await page.waitForURL(/\/dashboard/, { timeout: 15_000 });

  // 4. Save the authenticated state
  await context.storageState({ path: './test/.auth/clerk-session.json' });

  await browser.close();
}

export default globalSetup;
// Use the saved state in your tests
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  globalSetup: './test/setup/clerk-auth.ts',
  projects: [
    {
      name: 'authenticated',
      use: {
        storageState: './test/.auth/clerk-session.json',
      },
    },
    {
      name: 'unauthenticated',
      // No storageState: tests run without a session
    },
  ],
});
// Now your tests start already signed in
test('authenticated user can access dashboard', async ({ page }) => {
  await page.goto('/dashboard');

  // No sign-in needed. The session cookie is already set.
  await expect(page.getByRole('heading', { name: /dashboard/i }))
    .toBeVisible();
  await expect(page.getByText(/welcome back/i)).toBeVisible();
});

Assrt Equivalent

# scenarios/clerk-testing-token-ci.assrt
describe: Authenticated dashboard access via Testing Token

given:
  - I am signed in as the test user (via Clerk Testing Token)

steps:
  - navigate to /dashboard

expect:
  - the page shows the dashboard heading
  - the page shows a welcome back message
  - no sign-in redirect occurs

8. Scenario: Session Management (Sign Out, Switch Accounts)

6

Sign Out and Account Switching

Moderate

Clerk supports multi-session mode where users can be signed into multiple accounts simultaneously and switch between them. Testing session management is critical for apps that use this feature, and it is also important to verify that sign-out actually invalidates the session and redirects to the correct page.

Playwright Implementation: Sign Out

import { test, expect } from '@playwright/test';

test('sign out invalidates session and redirects', async ({ page }) => {
  // Start with an authenticated session (via storageState)
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: /dashboard/i }))
    .toBeVisible();

  // 1. Open the user menu (Clerk's UserButton component)
  await page.getByRole('button', { name: /open user menu|user button/i })
    .click();

  // 2. Click sign out in the dropdown
  await page.getByRole('menuitem', { name: /sign out/i }).click();

  // 3. Assert redirect to sign-in page
  await page.waitForURL(/\/sign-in/, { timeout: 10_000 });

  // 4. Verify the session is actually gone by trying to access
  //    a protected route
  await page.goto('/dashboard');
  await page.waitForURL(/\/sign-in/, { timeout: 10_000 });
});

Playwright Implementation: Account Switching

test('switch between multiple signed-in accounts', async ({ page }) => {
  // Assumes multi-session is enabled and two users are signed in
  await page.goto('/dashboard');

  // 1. Open the user menu
  await page.getByRole('button', { name: /open user menu/i }).click();

  // 2. Look for the account switcher section
  const otherAccount = page.getByRole('menuitem', {
    name: /switch account|other account/i,
  });
  await expect(otherAccount).toBeVisible();
  await otherAccount.click();

  // 3. Select the second account
  const secondAccount = page.getByText(process.env.SECOND_USER_EMAIL!);
  await expect(secondAccount).toBeVisible({ timeout: 5_000 });
  await secondAccount.click();

  // 4. Assert the dashboard now shows the second user's data
  await expect(page.getByText(process.env.SECOND_USER_EMAIL!))
    .toBeVisible({ timeout: 10_000 });
});

Assrt Equivalent

# scenarios/clerk-session-management.assrt
describe: Sign out and verify session invalidation

given:
  - I am signed in on the dashboard

steps:
  - open the user menu
  - click "Sign out"

expect:
  - I am redirected to /sign-in
  - navigating to /dashboard redirects me back to /sign-in
  - the session cookie is removed

9. Common Pitfalls That Break Real Test Suites

Component Rendering Timing

Clerk components load asynchronously. The <SignIn />component does not render its form fields until Clerk's JavaScript bundle has loaded and initialized. If your test tries to fill the email field before the component has mounted, it will fail with a "locator not found" error. Always use toBeVisible with an explicit timeout before interacting with any Clerk component element.

Testing Token Scope and Expiry

Testing Tokens are scoped to a specific Clerk instance (development or staging). A token generated for your development instance will not work against production. Tokens also expire; the default expiry is 300 seconds. If your global setup takes longer than that to complete, the token will be invalid by the time your first test runs. Set the expiry to match your test suite duration, and regenerate tokens in CI on every run rather than caching them.

Verification Code Retrieval in CI

If you are driving the actual Clerk UI in CI (rather than using Testing Tokens), you need a reliable way to get verification codes. The Clerk Backend API provides access to verification attempts, but the timing can be tricky. The code is sent asynchronously, and if your test reads the API before the code email is dispatched, you will get a stale or missing value. Add a retry loop with a short delay when polling for the code. Better yet, use Testing Tokens for CI and reserve the full UI flow for local development and staging.

Clerk Component Selectors Change Between Versions

Clerk regularly updates its component library, and the internal DOM structure can change between minor versions. Tests that rely on internal CSS class names or deeply nested element paths will break on updates. Use accessible selectors: getByLabel, getByRole, and getByText. These are based on the accessible name of elements, which Clerk maintains across versions for accessibility compliance.

Rate Limiting in Development Mode

Even in development mode, Clerk enforces rate limits on authentication attempts. If you run your full test suite in parallel with 10 workers, each creating sign-in attempts, you may hit rate limits and see intermittent 429 responses. Reduce parallelism for auth-heavy tests, or use Testing Tokens for the majority of your suite and only drive the UI for a small number of critical path tests.

Not Cleaning Up Test Users

Every sign-up test creates a real user in your Clerk instance. These accumulate over time and can make your development dashboard cluttered and harder to debug. Add a teardown step in your global teardown that deletes users created during the test run. Tag test users with a recognizable pattern in their email (such as e2e+timestamp@yourapp.com) and batch-delete them after the suite completes.

10. Writing These Scenarios in Plain English with Assrt

Every scenario above is 30 to 60 lines of Playwright TypeScript. Multiply that by the eight scenarios you need for full Clerk coverage and you have a 400-line test file that breaks the first time Clerk ships a component update that changes a label or restructures the DOM. Assrt lets you describe each scenario in plain English, generates the equivalent Playwright code, and regenerates the selectors automatically when the underlying component changes.

Here is what the complete Clerk test suite looks like as Assrt scenario files:

# scenarios/clerk-full-suite.assrt

---
describe: Email + verification code sign-in
given:
  - I am on the sign-in page
  - a test user exists with a known email
steps:
  - fill the email field with the test email
  - click "Continue"
  - fill the verification code with 424242
  - click "Verify"
expect:
  - I am on /dashboard within 15 seconds

---
describe: Google OAuth sign-in
given:
  - I am on the sign-in page
steps:
  - click "Continue with Google"
  - complete the Google login with test credentials
expect:
  - I am on /dashboard within 30 seconds

---
describe: Phone + SMS code sign-in
given:
  - I am on the sign-in page
steps:
  - switch to phone sign-in
  - fill the phone number
  - click "Continue"
  - fill the SMS code with 424242
  - click "Verify"
expect:
  - I am on /dashboard within 15 seconds

---
describe: New user sign-up
given:
  - I am on the sign-up page
  - I use a fresh random email
steps:
  - fill the email, password, first name, and last name
  - click "Continue"
  - fill the verification code with 424242
  - click "Verify"
expect:
  - I am on /dashboard or /onboarding within 15 seconds

---
describe: Sign out invalidates session
given:
  - I am signed in on the dashboard
steps:
  - open the user menu
  - click "Sign out"
expect:
  - I am on /sign-in
  - navigating to /dashboard redirects to /sign-in

Assrt compiles each block into the same Playwright TypeScript you saw in the sections above, committed to your repo as real tests you can read, run, and modify. When Clerk updates a component and renames a button label or changes the form structure, Assrt detects the failure, analyzes the new DOM, and opens a pull request with the updated locators. Your scenario files stay untouched.

npx assrt run clerk-full-suite

Start with the Testing Token scenario for CI. That gives you reliable authenticated test state in under a minute. Then add the email verification flow for visual coverage. Once those two are green, layer on OAuth, phone sign-in, sign-up, and session management. In a single afternoon you can have complete Clerk authentication coverage that most teams never manage to ship by hand.

The combination of Testing Tokens for speed and UI-driven tests for confidence is the pattern that works at scale. Assrt makes both approaches maintainable by keeping your test intent separate from the brittle selectors that change with every Clerk release.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk