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' });
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\"...}"
}]
}]
}
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\"...}}"
}]
}]
}
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:
-
handleRedirectCallback()makes a network request to Auth0's/oauth/tokenendpoint to exchange the authorization code - The token response comes back
- The SDK writes the user identity to localStorage (synchronous DOM operation)
- 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 }
);
}
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' });
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 });
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
);
Why Not Just Add a Delay?
You could do this:
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000); // "Should be enough"
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}
For example:
@@auth0spajs@@::abc123def456::https://api.jo4.io::openid profile email
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)