Authentication Testing Guide
How to Test Auth0 Universal Login with Playwright: Complete 2026 Guide
A scenario-by-scenario walkthrough of testing Auth0 Universal Login with Playwright. Cross-domain redirects, email/password login, social connections, MFA enrollment and challenge, email verification, session persistence in CI, and the pitfalls that break real auth test suites.
“Auth0 handles over two billion logins per month across tens of thousands of tenants, and Universal Login is the default experience for the majority of those applications.”
Auth0 Universal Login Flow
1. Why Testing Auth0 Universal Login Is Complex
Auth0 Universal Login is a hosted login page served from your tenant domain, typically yourapp.auth0.comor a custom domain you configure. When a user clicks “Log In” in your application, the browser navigates away from your domain entirely, renders the Auth0 login page, and then redirects back to your callback URL with an authorization code. That cross-domain redirect is the first obstacle. Playwright handles cross-origin navigations well, but your test must explicitly wait for domain changes rather than assuming it stays on a single origin.
The complexity multiplies from there. Social connections like Google or GitHub add a second redirect hop: your app redirects to Auth0, Auth0 redirects to the identity provider, the user authenticates there, the provider redirects back to Auth0, and Auth0 redirects back to your app. That is four domain transitions in a single login flow. MFA enrollment introduces a TOTP setup screen with a QR code that your test needs to parse programmatically. Auth0 Actions and Rules can inject custom logic at any point in the pipeline, modifying claims, blocking logins, or triggering additional verification steps.
There are five structural reasons this flow is hard to test reliably. First, the cross-domain redirect means your page context changes mid-test, and any locators from the previous domain become invalid. Second, the Lock widget (Classic Universal Login) and the New Universal Login page have completely different DOM structures, so upgrading your tenant can silently break every test. Third, social connections require real OAuth consent screens that may present CAPTCHAs. Fourth, MFA enrollment generates time-based codes that your test must compute in real time. Fifth, Auth0 tenant rate limits in development environments can throttle your test suite if you run too many login attempts in parallel.
Auth0 Universal Login Redirect Flow
Your App
User clicks Login
302 Redirect
/authorize
Auth0 Tenant
Universal Login page
User Authenticates
Email/password or social
302 Redirect
/callback?code=...
Token Exchange
Code for tokens
Your App
User is logged in
Social Connection Flow (Google via Auth0)
Your App
Click Login
Auth0 Tenant
/authorize
Google OAuth
accounts.google.com
User Consents
Google consent screen
Back to Auth0
Auth0 processes token
Your App
/callback with code
A good Auth0 Universal Login test suite covers all of these surfaces. The sections below walk through each scenario you need, with runnable Playwright TypeScript code you can copy directly.
2. Setting Up Your Test Environment
Before you write a single scenario, get the environment right. Auth0 provides a free development tenant that supports all features including social connections, MFA, and Actions. Create a dedicated test tenant separate from production. Never run end-to-end tests against your production tenant, because Auth0 rate limits are strict and a runaway test suite can lock out real users.
Auth0 Test Tenant Setup Checklist
- Create a dedicated Auth0 development tenant (separate from production)
- Enable the Username-Password-Authentication database connection
- Create a Regular Web Application and note the Client ID and Secret
- Create a Machine-to-Machine application with delete:users and create:users scopes
- Add localhost and CI preview URLs to Allowed Callback URLs
- Enable New Universal Login (not Classic) for reliable selectors
- Configure MFA policy (optional, for MFA scenarios)
- Disable bot detection and rate limiting for the test tenant
Environment Variables
Management API for Test User Cleanup
Every test run should start with a clean slate. Use the Auth0 Management API to delete and recreate test users before each suite. Create a Machine-to-Machine application in your test tenant with the delete:users and create:users scopes.
Playwright Configuration for Auth0
Auth0 Universal Login runs on a different domain than your app. Playwright needs to allow cross-origin navigation and handle the redirect chain without timing out. Set a generous navigation timeout and ensure your global setup creates fresh test users.
3. Scenario: Email/Password Login Happy Path
The first scenario every Auth0 integration needs is the basic email/password login that succeeds without any additional challenges. This is your smoke test. If this breaks, nobody can log in and you want to know immediately. The flow is straightforward: your app redirects to Auth0, the user enters credentials on the Universal Login page, Auth0 validates them and redirects back to your callback URL with an authorization code, and your backend exchanges that code for tokens.
Email/Password Login Happy Path
StraightforwardGoal
Starting from your app's login button, complete a full Auth0 Universal Login with email and password, land on the authenticated dashboard, and confirm the session is valid.
Preconditions
- App running at
APP_BASE_URL - Test user exists in Auth0 with verified email
- No MFA policies enabled for the test connection
Playwright Implementation
Assrt Equivalent
# scenarios/auth0-login-happy-path.assrt
describe: Email/password login through Auth0 Universal Login
given:
- I am on the home page
- a test user exists with verified email
steps:
- click "Log In"
- wait for the Auth0 login page to load
- fill the email field with the test user email
- fill the password field with the test user password
- click "Continue"
expect:
- I am redirected to /dashboard within 30 seconds
- the page shows a dashboard heading
- my email is displayed on the page
- a session cookie named "appSession" existsEmail/Password Login: Playwright vs Assrt
import { test, expect } from '@playwright/test';
test('email/password login: happy path', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /log in/i }).click();
await page.waitForURL(/\.auth0\.com/);
await page.getByLabel('Email address').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: /continue/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 30_000 });
await expect(page.getByRole('heading', { name: /dashboard/i }))
.toBeVisible();
await expect(page.getByText(process.env.TEST_USER_EMAIL!))
.toBeVisible();
const cookies = await page.context().cookies();
const sessionCookie = cookies.find(c => c.name === 'appSession');
expect(sessionCookie).toBeDefined();
});5. Scenario: Signup with Email Verification
When a new user signs up through Auth0 Universal Login, Auth0 can be configured to send a verification email before granting access. Testing this flow end-to-end means your test needs to create a new account, intercept or retrieve the verification email, extract the verification link, navigate to it, and confirm the account is now verified. This is one of the most fragile flows to test because it depends on email delivery.
The recommended approach for CI is to use the Auth0 Management API to check the verification status and, if needed, trigger verification programmatically. For a true end-to-end test, use a service like Mailosaur or a catch-all test email domain to capture the verification email.
Signup with Email Verification
ModeratePlaywright Implementation
import { test, expect } from '@playwright/test';
test('signup flow with email verification', async ({ page, request }) => {
const uniqueEmail = `signup+${Date.now()}@yourapp-test.mailosaur.net`;
const password = 'NewUser123!';
// 1. Navigate to signup
await page.goto('/');
await page.getByRole('button', { name: /sign up/i }).click();
await page.waitForURL(/\.auth0\.com/);
// 2. Switch to the Sign Up tab on Universal Login
await page.getByRole('link', { name: /sign up/i }).click();
// 3. Fill the signup form
await page.getByLabel('Email address').fill(uniqueEmail);
await page.getByLabel('Password').fill(password);
// 4. Submit
await page.getByRole('button', { name: /continue/i }).click();
// 5. Auth0 may show a "verify your email" interstitial
await expect(
page.getByText(/verify your email|check your email/i)
).toBeVisible({ timeout: 15_000 });
// 6. Retrieve the verification email via Mailosaur API
const mailosaurRes = await request.get(
`https://mailosaur.com/api/messages?server=YOUR_SERVER_ID&receivedAfter=${
new Date(Date.now() - 60_000).toISOString()
}&sentTo=${uniqueEmail}`,
{ headers: { Authorization: 'Basic YOUR_API_KEY' } }
);
const messages = await mailosaurRes.json();
const verifyLink = messages.items[0]?.html?.links?.find(
(l: { href: string }) => l.href.includes('verify')
)?.href;
expect(verifyLink).toBeTruthy();
// 7. Navigate to the verification link
await page.goto(verifyLink);
// 8. After verification, attempt login
await page.goto('/');
await page.getByRole('button', { name: /log in/i }).click();
await page.waitForURL(/\.auth0\.com/);
await page.getByLabel('Email address').fill(uniqueEmail);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /continue/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 30_000 });
await expect(page.getByText(uniqueEmail)).toBeVisible();
});Assrt Equivalent
# scenarios/auth0-signup-verification.assrt
describe: New user signup with email verification
given:
- I am on the home page
- I use a fresh random Mailosaur email
steps:
- click "Sign Up"
- wait for the Auth0 page to load
- switch to the Sign Up tab
- fill in email and password
- click "Continue"
- wait for the "verify your email" message
- retrieve the verification link from the inbox
- navigate to the verification link
- log in with the new credentials
expect:
- I am redirected to /dashboard within 30 seconds
- my email is displayed on the page6. Scenario: MFA Enrollment (TOTP Setup)
When MFA is enabled on your Auth0 tenant, users who log in for the first time are prompted to enroll in a second factor. The most common factor is TOTP (Time-based One-Time Password), where Auth0 presents a QR code containing a shared secret, the user scans it with an authenticator app, and then enters the six-digit code to confirm enrollment. Testing this in Playwright requires extracting the TOTP secret from the QR code or the manual entry link, then generating a valid code at test time.
The key insight is that Auth0 Universal Login provides both a QR code and a “Can't scan?” or “Having trouble?” link that reveals the raw TOTP secret as a text string. Your test clicks that link, extracts the secret, and uses a TOTP library to generate the current six-digit code.
MFA Enrollment: TOTP Setup
ComplexPlaywright Implementation
Assrt Equivalent
# scenarios/auth0-mfa-enrollment.assrt
describe: MFA TOTP enrollment during first login
given:
- a fresh test user exists with verified email
- MFA is required on the Auth0 tenant
steps:
- log in with email and password
- wait for the MFA enrollment screen
- click the manual entry link to reveal the TOTP secret
- extract the secret and generate a TOTP code
- enter the six-digit code
- click "Continue"
expect:
- I am redirected to /dashboard within 30 seconds
- MFA enrollment is complete for the user7. Scenario: MFA Challenge on Existing Account
Once a user has enrolled in MFA, every subsequent login triggers a challenge. The test needs to have the TOTP secret stored from enrollment (or configured via the Management API) so it can generate valid codes on demand. This scenario validates that the MFA challenge screen appears, accepts a correct code, rejects an incorrect code, and handles the recovery code fallback.
MFA Challenge on Existing Account
ModeratePlaywright Implementation
import { test, expect } from '@playwright/test';
import * as OTPAuth from 'otpauth';
// Store the TOTP secret from enrollment or set via Management API
const MFA_SECRET = process.env.TEST_USER_MFA_SECRET!;
test('MFA challenge: correct TOTP code succeeds', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /log in/i }).click();
await page.waitForURL(/\.auth0\.com/);
await page.getByLabel('Email address').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: /continue/i }).click();
// Auth0 presents the MFA challenge screen
await expect(
page.getByText(/enter.*verification code|enter.*code from/i)
).toBeVisible({ timeout: 15_000 });
// Generate valid TOTP code
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(MFA_SECRET),
digits: 6,
period: 30,
});
await page.getByLabel(/code/i).fill(totp.generate());
await page.getByRole('button', { name: /continue|verify/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 30_000 });
await expect(page.getByRole('heading', { name: /dashboard/i }))
.toBeVisible();
});
test('MFA challenge: incorrect code shows error', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /log in/i }).click();
await page.waitForURL(/\.auth0\.com/);
await page.getByLabel('Email address').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: /continue/i }).click();
await expect(
page.getByText(/enter.*verification code|enter.*code from/i)
).toBeVisible({ timeout: 15_000 });
// Enter an obviously wrong code
await page.getByLabel(/code/i).fill('000000');
await page.getByRole('button', { name: /continue|verify/i }).click();
// Assert error message appears
await expect(
page.getByText(/invalid code|incorrect|try again/i)
).toBeVisible({ timeout: 10_000 });
// Assert we are still on the MFA page, not redirected
await expect(page.url()).toContain('auth0.com');
});Assrt Equivalent
# scenarios/auth0-mfa-challenge.assrt
describe: MFA TOTP challenge on returning login
given:
- a test user exists with MFA TOTP enrolled
- the TOTP secret is available in the test environment
steps:
- log in with email and password
- wait for the MFA challenge screen
- generate a TOTP code from the stored secret
- enter the six-digit code
- click "Continue"
expect:
- I am redirected to /dashboard within 30 seconds
- the dashboard heading is visible8. Scenario: Bypassing Auth0 in CI with Saved Sessions
Running the full Auth0 login flow before every test is slow and fragile. Auth0 tenant rate limits can throttle parallel test workers, and any temporary Auth0 service hiccup will fail your entire suite. The solution is to authenticate once in a setup project, save the session state using Playwright's storageState, and reuse it across all subsequent tests. This pattern reduces your Auth0 API calls from N (one per test) to one (per test run).
For even faster CI runs, you can bypass the browser entirely and obtain tokens directly from Auth0's Resource Owner Password Grant endpoint. This approach skips the Universal Login page completely, generates valid access and ID tokens, and lets you inject them into the browser context as cookies or local storage entries.
Session Persistence with storageState
ModeratePlaywright Implementation: Browser-Based Setup
// test/global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// Perform the full login once
await page.goto(process.env.APP_BASE_URL + '/');
await page.getByRole('button', { name: /log in/i }).click();
await page.waitForURL(/\.auth0\.com/);
await page.getByLabel('Email address').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: /continue/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 30_000 });
// Save the authenticated session
await context.storageState({ path: './test/.auth/session.json' });
await browser.close();
}
export default globalSetup;API-Based Token Injection (Faster)
// test/helpers/auth0-token.ts
export async function getAuth0Token() {
const res = await fetch(
`https://${process.env.AUTH0_DOMAIN}/oauth/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'password',
client_id: process.env.AUTH0_CLIENT_ID,
client_secret: process.env.AUTH0_CLIENT_SECRET,
username: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
audience: process.env.AUTH0_AUDIENCE,
scope: 'openid profile email',
}),
}
);
return res.json();
}
// In global setup, inject the token into storage state:
// await context.addCookies([{
// name: 'appSession',
// value: encryptedSessionFromToken,
// domain: 'localhost',
// path: '/',
// }]);Assrt Equivalent
# scenarios/auth0-session-reuse.assrt
describe: Reuse authenticated session across tests
given:
- I have a saved session from a previous login
steps:
- load the saved session state
- navigate to /dashboard
expect:
- I am on the dashboard without being redirected to Auth0
- the session cookie is valid9. Common Pitfalls That Break Auth0 Test Suites
Tenant Rate Limits
Auth0 enforces strict rate limits on authentication endpoints, especially on free and development tier tenants. If you run ten parallel workers each performing a full login, you will hit the rate limit within seconds. The response is a 429 status code with no helpful error message on the Universal Login page. Use the session reuse pattern from Section 8 to minimize login calls, and configure your Playwright workers to a reasonable count (two to four) for auth-heavy suites.
Classic vs. New Universal Login
Auth0 supports two Universal Login experiences: Classic (using the Lock widget, a JavaScript SDK that renders its own DOM) and New (a server-rendered page with standard HTML form elements). The Lock widget has its own internal input fields, shadow DOM elements, and animation timings that make it significantly harder to automate. If your tenant still uses Classic Universal Login, strongly consider migrating to New Universal Login before writing your test suite. If you cannot migrate, you will need Lock-specific selectors like .auth0-lock-input and explicit waits for the Lock animation to complete.
Callback URL Mismatch
Auth0 validates callback URLs strictly. If your test environment runs on http://localhost:3000 but your Auth0 application settings only list https://app.yourcompany.com, the redirect will fail with an opaque error. Add every environment URL (localhost with port, CI preview URLs, staging) to the Allowed Callback URLs list in your Auth0 application settings. For CI environments with dynamic URLs, use a wildcard pattern if your Auth0 plan supports it.
Auth0 Actions Modifying the Flow
Auth0 Actions (formerly Rules and Hooks) run server-side during the authentication pipeline. An Action that blocks first-time logins, requires email verification, enriches tokens with custom claims, or redirects to an external consent page will change the login flow your test expects. Audit your Actions pipeline before writing tests, and consider creating a test-specific Action that skips non-essential steps when the login comes from a known test email domain.
Stale Test Users
Unlike Stripe where you can generate a fresh test card for each run, Auth0 test users accumulate state. A user who has enrolled in MFA, verified their email, or been blocked by a brute-force rule will behave differently on the next test run. Always clean up test users with the Management API before each suite. Delete the user entirely and recreate them fresh, rather than trying to reset individual properties. This eliminates an entire class of flaky test failures.
10. Writing These Scenarios in Plain English with Assrt
Every scenario above is 30 to 60 lines of Playwright TypeScript. Multiply that by the seven scenarios you actually need and you have a substantial test file that silently breaks the first time Auth0 updates the Universal Login page layout, renames a button label, or changes the MFA enrollment DOM structure. Assrt lets you describe the scenario in plain English, generates the equivalent Playwright code, and regenerates the selectors automatically when the underlying page changes.
The MFA enrollment scenario from Section 6 demonstrates the power of this approach. In raw Playwright, you need to know the exact selector for the “Can't scan?” link, the data attribute that holds the TOTP secret, and the label pattern for the code input. In Assrt, you describe the intent and let the framework resolve the selectors at runtime.
# scenarios/auth0-full-suite.assrt
describe: Complete Auth0 Universal Login test suite
---
scenario: Happy path email/password login
steps:
- click "Log In"
- wait for the Auth0 login page
- fill email with the test user email
- fill password with the test user password
- click "Continue"
expect:
- I am on /dashboard within 30 seconds
---
scenario: MFA enrollment with TOTP
steps:
- log in with a fresh user that has no MFA enrolled
- wait for the MFA setup screen
- click the manual entry option
- extract the TOTP secret
- generate and enter a valid TOTP code
- click "Continue"
expect:
- I am on /dashboard
- MFA is enrolled for the user
---
scenario: Social login initiates Google redirect
steps:
- click "Log In"
- wait for the Auth0 login page
- click "Continue with Google"
expect:
- I am redirected to accounts.google.com
---
scenario: Invalid credentials show error
steps:
- click "Log In"
- wait for the Auth0 login page
- fill email with "wrong@example.com"
- fill password with "WrongPassword"
- click "Continue"
expect:
- an error message about wrong credentials is visible
- I am still on the Auth0 login pageAssrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections, committed to your repo as real tests you can read, run, and modify. When Auth0 renames a button from “Continue” to “Log In” or restructures the MFA enrollment page, Assrt detects the failure, analyzes the new DOM, and opens a pull request with the updated locators. Your scenario files stay untouched.
Start with the email/password happy path. Once it is green in your CI, add the MFA enrollment scenario, then the MFA challenge, then social connection verification, then the session reuse pattern, then the signup flow. In a single afternoon you can have complete Auth0 Universal Login coverage that most production applications never manage to achieve by hand.
Related Guides
How to Test Azure AD Login
A practical, scenario-by-scenario guide to testing Azure AD (Entra ID) login with...
How to Test Clerk Sign-In
A practical, scenario-by-scenario guide to testing Clerk authentication with Playwright....
How to Test Firebase Auth
A practical guide to testing Firebase Authentication with Playwright. Covers the emulator...
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.
4. Scenario: Social Connection (Google via Auth0)
Social connections add another redirect hop to the login flow. When a user clicks “Continue with Google” on the Auth0 Universal Login page, Auth0 redirects to Google's OAuth consent screen. The user authenticates with Google, Google redirects back to Auth0 with a code, Auth0 exchanges it for a Google token, creates or links the user, and redirects back to your app. Testing this end-to-end in CI is notoriously difficult because Google may present CAPTCHAs, enforce device trust checks, or require 2FA.
The practical approach is to test two things separately. First, test that clicking the social button on Auth0 initiates the correct redirect to the provider. Second, use the Auth0 Management API to simulate the social login result by creating a user with the social identity already linked, then test the downstream session handling.
Social Connection: Google via Auth0
ComplexPlaywright Implementation
Assrt Equivalent