DEV Community

Anand Rathnas
Anand Rathnas

Posted on • Originally published at jo4.io

Why Auth0 E2E Tests Fail After Login: The localStorage Race Condition

This article was originally published on Jo4 Blog.

Your Playwright test logs into Auth0 successfully. The redirect completes. The app loads. You snapshot storageState() for subsequent tests. Every test after that gets 401 Unauthorized on API calls.

The login worked. The token didn't persist.

TL;DR

Auth0 SPA SDK v2 writes two keys to localStorage inside handleRedirectCallback(): the user identity and the access token. Playwright's waitForLoadState('networkidle') fires before these writes complete because they're synchronous DOM operations, not network requests. Snapshotting too early captures the user key but misses the access token. Fix: use page.waitForFunction() to poll for the exact access token key.

The Symptom

Here's what the test looked like:

// global-setup.ts
const page = await browser.newPage();
await page.goto('/');

// Auth0 redirects to login page
await page.fill('[name="username"]', process.env.TEST_USER);
await page.fill('[name="password"]', process.env.TEST_PASSWORD);
await page.click('[type="submit"]');

// Wait for the app to load after Auth0 redirects back
await page.waitForLoadState('networkidle');

// Save auth state for all tests
await page.context().storageState({ path: 'auth.json' });
Enter fullscreen mode Exit fullscreen mode

This works 60% of the time. The other 40%, every subsequent test fails with 401.

The auth.json file holds the answer. On a failing run, it contains one localStorage key:

{
  "origins": [{
    "localStorage": [{
      "name": "@@auth0spajs@@::user::@@user@@",
      "value": "{\"sub\":\"auth0|abc123\",\"email\":\"test@example.com\"...}"
    }]
  }]
}
Enter fullscreen mode Exit fullscreen mode

On a passing run, it contains two:

{
  "origins": [{
    "localStorage": [{
      "name": "@@auth0spajs@@::user::@@user@@",
      "value": "{\"sub\":\"auth0|abc123\"...}"
    }, {
      "name": "@@auth0spajs@@::your-client-id::your-audience::openid profile email",
      "value": "{\"body\":{\"access_token\":\"eyJ...\",\"token_type\":\"Bearer\"...}}"
    }]
  }]
}
Enter fullscreen mode Exit fullscreen mode

The second key -- the one with the access token -- is missing on failing runs. The first key (user identity) is present either way.

The Root Cause

Auth0 SPA SDK v2 handles the OAuth callback in this sequence:

  1. handleRedirectCallback() makes a network request to Auth0's /oauth/token endpoint to exchange the authorization code
  2. The token response comes back
  3. The SDK writes the user identity to localStorage (synchronous DOM operation)
  4. The SDK writes the access token to localStorage (synchronous DOM operation)

Steps 3 and 4 are synchronous DOM writes. They're not network requests. Playwright's waitForLoadState('networkidle') triggers when there are no pending network requests for 500ms. That condition is met after step 2 completes -- the /oauth/token response arrived and there's nothing else in flight.

But steps 3 and 4 haven't executed yet. They're queued in the JavaScript event loop, waiting for the current microtask/macrotask to finish. networkidle fires, your test proceeds to storageState(), and you capture a snapshot mid-write.

Sometimes you catch both keys. Sometimes you only catch the first. It depends on exactly when the event loop gets to those writes relative to when Playwright reads localStorage. Classic race condition.

The Fix

14 lines:

async function waitForAuth0Token(page) {
  await page.waitForFunction(
    (clientId) => {
      const keys = Object.keys(localStorage);
      return keys.some(key =>
        key.startsWith('@@auth0spajs@@') &&
        key.includes(clientId) &&
        !key.includes('@@user@@')
      );
    },
    process.env.AUTH0_CLIENT_ID,
    { timeout: 30000 }
  );
}
Enter fullscreen mode Exit fullscreen mode

This polls localStorage until the access token key appears. Not the user key (which arrives first and is useless on its own), but the key that contains the client ID and audience -- the one with the actual access token.

The updated setup:

// global-setup.ts
await page.click('[type="submit"]');
await page.waitForLoadState('networkidle');
await waitForAuth0Token(page);  // Wait for the ACTUAL token
await page.context().storageState({ path: 'auth.json' });
Enter fullscreen mode Exit fullscreen mode

We keep waitForLoadState('networkidle') as a coarse wait for the page to settle, then add the precise wait for the specific localStorage key we need.

The waitForFunction Gotcha

One thing that cost me 30 minutes: the function signature.

// WRONG - opts is passed as the function's argument
await page.waitForFunction(fn, { timeout: 30000 });

// RIGHT - null is the function argument, opts is the third param
await page.waitForFunction(fn, null, { timeout: 30000 });
Enter fullscreen mode Exit fullscreen mode

page.waitForFunction() takes three arguments: the function, the argument to pass to that function, and the options object. If you pass two arguments, Playwright treats the second as the function argument, not as options. Your timeout never gets applied, and you get the default 30-second timeout anyway (lucky coincidence in this case) or a confusing type error.

When you need to pass an actual argument and options:

await page.waitForFunction(
  (clientId) => { /* use clientId */ },
  process.env.AUTH0_CLIENT_ID,  // Passed to the function as clientId
  { timeout: 30000 }            // Playwright options
);
Enter fullscreen mode Exit fullscreen mode

Why Not Just Add a Delay?

You could do this:

await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);  // "Should be enough"
Enter fullscreen mode Exit fullscreen mode

It works until it doesn't. On a slow CI runner, 2 seconds isn't enough. On a fast local machine, you're wasting 2 seconds per run for no reason. Fixed delays in tests are a code smell that guarantees flakiness at scale.

The waitForFunction approach is deterministic. It resolves the moment the token appears, whether that's 50ms or 5 seconds after networkidle.

The Key Pattern

The Auth0 SPA SDK localStorage key follows this pattern:

@@auth0spajs@@::{clientId}::{audience}::{scope}
Enter fullscreen mode Exit fullscreen mode

For example:

@@auth0spajs@@::abc123def456::https://api.jo4.io::openid profile email
Enter fullscreen mode Exit fullscreen mode

The @@user@@ key follows a different pattern and only contains identity claims (sub, email, name). It does NOT contain the access token. If your test only needs to verify the user is logged in, the user key is sufficient. If your test makes API calls, you need the access token key.

Summary

What Fires When Good Enough?
waitForLoadState('networkidle') No network requests for 500ms No -- misses localStorage writes
waitForTimeout(2000) After 2 seconds Flaky -- arbitrary delay
waitForFunction(checkLocalStorage) Token key exists in localStorage Yes -- deterministic

The race condition exists because networkidle is a network-level signal, and localStorage writes are a DOM-level operation. They live in different layers. You need to wait at the layer where the state actually lives.


Hit this exact issue? Let me know in the comments -- I'm curious how many teams have independently discovered this race condition.

Building jo4.io -- E2E tested from Auth0 login to payout settlement.

Top comments (0)