ProductHow it worksPricingBlogDocsLoginFind Your First Bug
Auth0 testing diagram: the Resource Owner Password grant flow showing a test runner posting credentials to /oauth/token, receiving an access token, and injecting it into the app under test
TestingAuth0Authentication+1

How to Test Auth0 Login in an E2E Suite

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

Auth0 E2E testing: the standard approach is the Resource Owner Password (ROP) grant. Your test posts credentials directly to Auth0's /oauth/token endpoint, receives an access token, and injects it into the app under test instead of driving Universal Login through a browser. Auth0 and the OAuth spec explicitly discourage this grant for production use (it bypasses MFA, brute-force protection, and the hosted login UI), but it is the accepted, documented pattern for automated testing against a non-production tenant. Be honest about the tradeoff: it tests your app's authenticated surface, not the login UI your real users see.

You point Playwright at your app's login button. The browser clicks it. Auth0 Universal Login takes over: a cross-origin redirect to a domain you do not own, a form whose markup you did not write, protected against automation in ways Auth0 controls entirely. Your selectors do not exist yet. Even if you found them, Auth0 can change them without notice. You have no callback to intercept, no contract to stub. The hosted login page is just a wall.

This is the problem every team hits when they try to drive Auth0 login through a real browser in a test runner. The honest answer is that you mostly should not. There is a cleaner path that most production test suites use, it has a known tradeoff worth understanding, and it involves a specific OAuth grant that Auth0 documents but flags as high-risk for non-test contexts.

For the broader Playwright authentication testing pattern, the Playwright authentication testing guide covers session persistence and the storageState approach across providers. Auth0 has its own specific token-minting path that differs from how you handle providers like NextAuth; if you are using Auth.js in your stack, the NextAuth login testing guide covers the session cookie injection approach for that provider. This post covers the Auth0-specific path: minting a token directly via the ROP grant, getting it into your app, and being clear about what you give up.

Why Auth0 Universal Login is Hard to Automate

Auth0 Universal Login is a hosted UI that lives on Auth0's own domain, not yours. When your app redirects to it, you leave your origin. Playwright's same-origin constraints mean that if you try to drive the login form directly, you are operating in a cross-origin context your test runner did not set up and cannot fully control.

Beyond origin issues, the hosted page itself is a moving target. Auth0 manages its markup, its JavaScript, and its bot-detection behavior. The selectors are not your selectors. If Auth0 redesigns the form or adds a Captcha challenge, your test breaks without any change to your own codebase.

There is also a category of active bot detection Auth0 runs on suspicious automation patterns: timing heuristics, user-agent flags, headless browser signals. Not as aggressive as Google's detection, but present, and it varies by tenant configuration and plan tier.

The result is that driving Universal Login from a test runner is brittle enough to be a losing strategy for most teams. The auth logic you actually need to test lives downstream of the login form: does your app correctly handle the authenticated session? Are protected routes accessible to a logged-in user? Does the access token get passed correctly? These questions can be answered without ever touching the login UI.

Using the Resource Owner Password Grant for Tests

The Resource Owner Password (ROP) grant lets a test exchange a username and password directly for an access token by posting to Auth0's /oauth/token endpoint. No browser, no redirect, no Universal Login. Your test runner gets a token in under a second and moves on.

Important caveat up front. Auth0's documentation flags this grant as unsuitable for production applications. It requires your application to handle raw credentials, bypasses MFA, skips brute-force protection, and removes the hosted login from the flow. Auth0 recommends it only for testing and legacy integrations, and the OAuth spec broadly discourages it. Use it against a dedicated non-production tenant with test accounts. Never enable it on your production tenant.

With that said, here is how to configure it.

Resource Owner Password grant flow showing a test runner posting credentials to Auth0's oauth token endpoint, receiving an access token, and injecting that token into the app under test while Universal Login is bypassed
The ROP grant mints a token through Auth0's API and injects it into the browser, which skips Universal Login entirely.

Enable the grant type on the application. In your Auth0 tenant's dashboard, navigate to Applications, open the application that represents your E2E test suite (or your app under test), and go to Settings. Under Advanced Settings, find the Grant Types section. Enable "Password" (or "Password Realm" if you need the realm-targeted variant). Save.

Set a Default Directory. The realm-less password grant requires Auth0 to know which connection (database connection, user directory) to authenticate against. Go to your tenant's General Settings and set a Default Directory to the name of your database connection (for example, "Username-Password-Authentication"). Without this, Auth0 returns an error when you post to /oauth/token with grant_type=password.

The request shape. A basic ROP grant request posts grant_type=password, username, password, client_id, and optionally client_secret (required for confidential applications), audience, and scope. The password-realm variant uses grant_type=http://auth0.com/oauth/grant-type/password-realm and adds a realm field specifying the connection name. This is useful when your tenant has multiple connections and you cannot or do not want to set a Default Directory.

Here is the full curl request to mint an access token:

#!/usr/bin/env bash
# fetch-token.sh
# Mint an Auth0 access token via the Resource Owner Password grant.
#
# Prerequisites:
#   - curl installed
#   - An Auth0 non-production tenant with:
#       * Password grant type enabled on the application
#       * A Default Directory set (e.g. "Username-Password-Authentication")
#
# Usage:
#   export AUTH0_DOMAIN="dev-xxxx.us.auth0.com"
#   export CLIENT_ID="your-client-id"
#   export CLIENT_SECRET="your-client-secret"
#   export TEST_USER_EMAIL="test@example.com"
#   export TEST_USER_PASSWORD="TestPassword123!"
#   export AUTH0_AUDIENCE="https://your-api-identifier"
#   export AUTH0_SCOPE="openid profile email"
#   bash fetch-token.sh
#
# The script exits with a non-zero code if any required variable is missing
# or if the Auth0 request fails.

set -euo pipefail

# ---- Validate required environment variables ----
required_vars=(
  AUTH0_DOMAIN
  CLIENT_ID
  CLIENT_SECRET
  TEST_USER_EMAIL
  TEST_USER_PASSWORD
  AUTH0_AUDIENCE
  AUTH0_SCOPE
)

for var in "${required_vars[@]}"; do
  if [[ -z "${!var:-}" ]]; then
    echo "ERROR: Required environment variable \$${var} is not set." >&2
    exit 1
  fi
done

# ---- POST to /oauth/token with the Resource Owner Password grant ----
#
# grant_type=password  : the Resource Owner Password grant
# username             : the test user's email address
# password             : the test user's password
# client_id            : identifies the application configured in your Auth0 tenant
# client_secret        : required for confidential applications
# audience             : the API identifier that the returned access token should be valid for
# scope                : space-separated list of requested OAuth scopes
#
RESPONSE=$(curl --silent --fail --show-error \
  --request POST \
  --url "https://${AUTH0_DOMAIN}/oauth/token" \
  --header "Content-Type: application/json" \
  --data "{
    \"grant_type\": \"password\",
    \"username\": \"${TEST_USER_EMAIL}\",
    \"password\": \"${TEST_USER_PASSWORD}\",
    \"client_id\": \"${CLIENT_ID}\",
    \"client_secret\": \"${CLIENT_SECRET}\",
    \"audience\": \"${AUTH0_AUDIENCE}\",
    \"scope\": \"${AUTH0_SCOPE}\"
  }")

# ---- Output the full JSON response ----
#
# A successful response looks like:
# {
#   "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ...",
#   "id_token":     "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ...",
#   "token_type":   "Bearer",
#   "expires_in":   86400
# }
echo "${RESPONSE}"

The response includes access_token, id_token, token_type, and expires_in. Take the access_token (and id_token if your app uses it) and move to the injection step.

Injecting the Token and Asserting Access

Getting the token is half the job. Getting it into a running browser context in the right place is the other half. Where you inject it depends on how your app reads auth state.

If your app stores auth in localStorage: inject the token via page.evaluate() before navigating. Set the keys your app's auth SDK writes: typically something like @@auth0spajs@@::{clientId}::{audience}::openid profile email as a serialized JSON object. This is the key format Auth0's SPA SDK uses internally. Check your browser's localStorage after a real login to get the exact key name.

If your app uses HTTP-only cookies: you cannot inject via JavaScript. Set the cookie via context.addCookies() with the correct domain, path, and httpOnly: true flags before the first navigation. The cookie value and name will match whatever your backend sets after exchanging the token.

If your app sends the token as an Authorization header: there is no persistent state to inject. Instead, intercept outgoing API requests via page.route() and add the Authorization: Bearer {token} header to requests matching your API routes.

After injection, navigate to a protected route and assert that it loads. A route your app redirects to login if the user is unauthenticated is the clearest signal: if it renders without a login redirect, the injection worked. Check for authenticated-only UI elements to confirm the session is being read correctly.

Here is a Playwright setup fixture that fetches the token and injects it before any test runs:

/**
 * fixtures/auth.ts
 *
 * Playwright test fixture that mints an Auth0 access token via the Resource
 * Owner Password grant, then injects it into localStorage using the Auth0
 * SPA SDK key format before navigating to a protected route.
 *
 * Required environment variables (copy .env.example → .env):
 *   AUTH0_DOMAIN        e.g. dev-xxxx.us.auth0.com
 *   AUTH0_CLIENT_ID     application client ID from Auth0 dashboard
 *   AUTH0_CLIENT_SECRET application client secret (confidential apps only)
 *   AUTH0_AUDIENCE      API identifier, e.g. https://your-api-identifier
 *   TEST_USER_EMAIL     test account email (non-production tenant only)
 *   TEST_USER_PASSWORD  test account password
 *
 * Usage:
 *   import { test, expect } from '../fixtures/auth';
 *
 *   test('protected page loads', async ({ authenticatedPage }) => {
 *     await expect(authenticatedPage.locator('h1')).toBeVisible();
 *   });
 */

import { test as base, Page } from '@playwright/test';

// ---- Types ----

/** Shape of a successful Auth0 /oauth/token ROP response. */
interface Auth0TokenResponse {
  access_token: string;
  id_token: string;
  token_type: string;
  expires_in: number;
}

/** Shape of the object Auth0's SPA SDK writes to localStorage. */
interface Auth0LocalStorageEntry {
  body: {
    access_token: string;
    id_token: string;
    token_type: string;
    expires_in: number;
    decodedToken: {
      claims: Record<string, unknown>;
      user: Record<string, unknown>;
    };
  };
  expiresAt: number;
}

// ---- Fixture type declaration ----

type AuthFixtures = {
  /** A Playwright Page with Auth0 token already injected; ready to navigate to protected routes. */
  authenticatedPage: Page;
};

// ---- Token fetch helper ----

/**
 * Calls Auth0's /oauth/token endpoint with the Resource Owner Password grant
 * and returns the raw token response.
 */
async function fetchAuth0Token(): Promise<Auth0TokenResponse> {
  const domain = process.env.AUTH0_DOMAIN;
  const clientId = process.env.AUTH0_CLIENT_ID;
  const clientSecret = process.env.AUTH0_CLIENT_SECRET;
  const audience = process.env.AUTH0_AUDIENCE;
  const username = process.env.TEST_USER_EMAIL;
  const password = process.env.TEST_USER_PASSWORD;

  const missing = [
    ['AUTH0_DOMAIN', domain],
    ['AUTH0_CLIENT_ID', clientId],
    ['AUTH0_CLIENT_SECRET', clientSecret],
    ['AUTH0_AUDIENCE', audience],
    ['TEST_USER_EMAIL', username],
    ['TEST_USER_PASSWORD', password],
  ]
    .filter(([, v]) => !v)
    .map(([k]) => k);

  if (missing.length > 0) {
    throw new Error(
      `Missing required environment variables: ${missing.join(', ')}. ` +
        'Copy .env.example to .env and fill in the values.'
    );
  }

  const url = `https://${domain}/oauth/token`;

  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'password',
      username,
      password,
      client_id: clientId,
      client_secret: clientSecret,
      audience,
      scope: 'openid profile email',
    }),
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error(
      `Auth0 token request failed (${response.status} ${response.statusText}): ${errorBody}`
    );
  }

  return response.json() as Promise<Auth0TokenResponse>;
}

// ---- Fixture definition ----

/**
 * Extended Playwright `test` that provides an `authenticatedPage` fixture.
 *
 * The fixture:
 *   1. Fetches an Auth0 access token via the ROP grant.
 *   2. Navigates to a blank page so localStorage is accessible.
 *   3. Injects the token into localStorage using the Auth0 SPA SDK key format:
 *      @@auth0spajs@@::{clientId}::{audience}::openid profile email
 *   4. Navigates to the protected route defined by PLAYWRIGHT_PROTECTED_URL
 *      (falls back to "/" if unset — override in your playwright.config.ts).
 */
export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    const clientId = process.env.AUTH0_CLIENT_ID!;
    const audience = process.env.AUTH0_AUDIENCE!;

    // 1. Fetch the token from Auth0
    const tokenResponse = await fetchAuth0Token();

    // 2. Build the localStorage key the Auth0 SPA SDK expects.
    //    The key format is: @@auth0spajs@@::{clientId}::{audience}::{scope}
    //    Inspect your browser's localStorage after a real login to confirm the
    //    exact key your app writes.
    const storageKey = `@@auth0spajs@@::${clientId}::${audience}::openid profile email`;

    // 3. Build the localStorage value.
    //    decodedToken.claims / user are decoded from the JWT at runtime by the
    //    SDK. We set them to empty objects here; Auth0's SPA SDK re-hydrates
    //    them from the stored tokens on page load.
    const storageValue: Auth0LocalStorageEntry = {
      body: {
        access_token: tokenResponse.access_token,
        id_token: tokenResponse.id_token,
        token_type: tokenResponse.token_type,
        expires_in: tokenResponse.expires_in,
        decodedToken: {
          claims: {},
          user: {},
        },
      },
      // expiresAt is a Unix timestamp (seconds)
      expiresAt: Math.floor(Date.now() / 1000) + tokenResponse.expires_in,
    };

    // 4. Navigate to a minimal page so we can set localStorage.
    //    We use "about:blank" and then set localStorage before the real navigation
    //    to avoid a CSP rejection on some apps.
    const appOrigin = process.env.PLAYWRIGHT_APP_ORIGIN ?? 'http://localhost:3000';
    await page.goto(appOrigin);

    // 5. Inject the token into localStorage.
    await page.evaluate(
      ([key, value]: [string, string]) => {
        window.localStorage.setItem(key, value);
      },
      [storageKey, JSON.stringify(storageValue)]
    );

    // 6. Navigate to the protected route. After this navigation the app should
    //    read localStorage, find the token, and render as authenticated.
    const protectedPath =
      process.env.PLAYWRIGHT_PROTECTED_PATH ?? '/dashboard';
    await page.goto(`${appOrigin}${protectedPath}`);

    // 7. Hand the authenticated page to the test.
    await use(page);
  },
});

export { expect } from '@playwright/test';

How Autonoma Covers the Login UI the Shortcut Skips

The ROP grant approach is the right call for most E2E suites. It is fast, reliable, and keeps your tests off a hosted page whose markup Auth0 controls. For testing your app's authenticated surface (protected routes, token handling, session behavior, API access) it does exactly what you need. But it is worth being honest about what it does not cover.

The actual login redirect, the form, the callback, the session handoff: those go untested by a suite that bypasses them with a token. The most common Auth0 production incidents are not broken API calls. They are broken redirect chains. A misconfigured callback URL. A custom login page that stops rendering after an update. A PKCE flow that fails because the allowed callback list was changed during a deploy. A session that never gets established because the post_login_redirect_uri is wrong. Your tests stay green. Your users cannot log in.

Auth0 testing diagram contrasting the ROP token shortcut, which bypasses Universal Login and callback handling, with the real browser login path through Universal Login, callback validation, session creation, and authenticated-state checks
The token shortcut covers authenticated app behavior; the real browser path covers Universal Login, callback handling, and the session handoff users depend on.

That is precisely what Autonoma was built to address. Our Planner agent reads your codebase and identifies the auth flows your application uses: the routes, the redirect patterns, the session handling. It generates test cases that drive the actual login UI through a per-PR preview environment, the same URL a real user would hit. The Executor agent runs those tests in a real browser, navigating Universal Login and checking the authenticated state after callback instead of relying only on token injection. The Reviewer agent separates genuine auth regressions from agent errors so you are not chasing false positives every time Auth0's UI shifts slightly.

For scenario setup, Autonoma can use its SDK/data factory to create the users those auth tests need. You wire your own create and teardown functions through @autonoma-ai/sdk factories; the SDK pattern uses defineFactory, createHandler, create, and teardown that calls userRepo.delete(user.id). That lets Autonoma create users with specific characteristics or states, authenticate scenarios with those users, and delete the same users when the run finishes. This is app-side test-data setup, not a native Auth0 Management API integration claim.

The Diffs Agent is where the maintenance story changes. When your Auth0 configuration changes (a new callback URL, an updated allowed origins list, a modified login page template), the Diffs Agent reads the code changes on the next PR and updates the test plan accordingly. You still keep the ROP fixture for fast authenticated-surface coverage, but Autonoma reduces the manual upkeep around the login routes, callback assumptions, and UI state that the token shortcut cannot see.

Token Shortcut vs Testing the Real Login: the Honest Tradeoff

The combination is what most production suites need: the ROP grant for fast authenticated-surface coverage in the bulk of the suite, and an agent-driven layer to cover the login UI the token shortcut cannot see.

What the token shortcut coversWhat it skips
App behavior when authenticatedUniversal Login form and redirect
Protected route access controlAuth0 callback URL configuration
Token-gated API requestsPost-login redirect handling
Session expiry and refresh logicMFA, social login, brute-force protection

The token shortcut also rots in ways that are quiet and hard to diagnose. If someone changes the Default Directory on the tenant, token minting fails silently in the test setup. If the grant type gets disabled on the application during a security review, the test setup errors out with an OAuth error that looks like a misconfiguration. The tenant config assumptions embedded in your test setup are invisible in code review. If you also use Auth0 to front enterprise SSO flows, those connections introduce additional configuration state that the ROP shortcut never exercises.

Both patterns running together give you the full picture: your app's behavior when authenticated, and your login flow's behavior when a real user actually tries to get there.

FAQ

The standard E2E approach is the Resource Owner Password (ROP) grant: post credentials directly to Auth0's /oauth/token endpoint from your test setup, receive an access token, and inject it into your app's browser context before navigating to protected routes. This avoids driving Universal Login through a browser. The caveat: this tests your app's authenticated surface, not the login UI itself. Auth0 recommends using the ROP grant only against non-production tenants with test accounts.

The Resource Owner Password grant is an OAuth 2.0 grant type that lets a client post a username and password directly to the authorization server's token endpoint and receive an access token in response, without any browser redirect. Auth0 supports it but flags it as discouraged for production use because it requires handling raw credentials and bypasses MFA and brute-force protection. For automated testing against a non-production Auth0 tenant, it is the accepted shortcut for minting tokens quickly without driving the hosted login UI.

Technically yes, but it is brittle and generally the wrong approach. Universal Login lives on Auth0's domain, not yours. Its markup is controlled by Auth0 and can change without notice. Auth0 also runs bot-detection checks that can block automation. The practical alternative for most E2E suites is to bypass Universal Login entirely using the Resource Owner Password grant to mint an access token, then inject the token into your app's browser context. This covers your app's authenticated behavior without coupling your tests to a hosted page you do not control.

Post to your Auth0 tenant's /oauth/token endpoint with grant_type=password, username, password, client_id, client_secret (if the app is confidential), audience, and scope. Your tenant must have a Default Directory set and the Password grant type enabled on the application. The response includes an access_token and id_token you can inject into your test's browser context. Use a dedicated non-production tenant and test accounts. Never enable the Password grant on a production Auth0 tenant.

It depends on what you need to cover. Mocking the OAuth redirect (stubbing the token exchange at your app's callback) is good for testing your application's OAuth callback logic: what happens on success, on denial, on token exchange failure. It does not test the end-to-end login flow. The Resource Owner Password grant is better than mocking for getting an authenticated state quickly without driving the hosted UI. Neither approach tests Universal Login itself. For that you need a browser driving the real login page, which is where an agent-driven approach like Autonoma covers the gap.

Related articles

Diagram showing a Playwright auth setup flow: a login-once setup test saves storageState to a JSON file, multiple parallel worker tests load the state and skip login entirely, cutting per-test auth time from seconds to milliseconds

Playwright Authentication: Cut Login Time by 80% with storageState

Playwright authentication with storageState: log in once, reuse auth state, and cut test login time by 60-80%. Covers multi-role, API login, Clerk, Auth0, and Supabase.

Browser automation flow diagram showing a Google OAuth redirect being intercepted and stubbed, with a storageState file persisting an authenticated session on a dark background

How to Test Google OAuth Login Without Getting Blocked

How to test Google OAuth login without getting blocked: capture a session once and reuse it via storageState, mock the OAuth redirect, or use a test account.

Diagram showing a Clerk-protected route test flow: Testing Token bypasses bot detection, programmatic sign-in establishes a session, and assertions check that the protected route loads for authed users and redirects for unauthenticated ones

How to Test Clerk Authentication End-to-End

How to test Clerk authentication end-to-end: use Testing Tokens to bypass bot detection, sign in programmatically, and assert that protected routes load or redirect correctly.

Diagram of a Supabase auth session lifecycle showing access and refresh tokens flowing from supabase-js into a browser localStorage and a Playwright test context on a dark background

How to Test Supabase Auth End-to-End

Test Supabase Auth end-to-end: sign in via supabase-js, persist the session, beat magic-link CI flakiness, and cover the real login screen.