Handling OAuth and SSO Redirects in Tests
A test that signs in through Okta, Auth0, Microsoft Entra ID, or Google Workspace does not perform one navigation — it performs a chain of five to nine cross-origin navigations, at least two of which are server-issued 302 responses your test never explicitly requests. Anything you write that assumes the browser is still on the application origin will race that chain and fail with Error: page.waitForURL: Timeout 30000ms exceeded or Execution context was destroyed, most likely because of a navigation. This page shows how to drive the redirect chain deterministically, capture the resulting session once, and reuse it across every worker.
Root cause: the test outruns a chain it never issued
page.goto() resolves when the first navigation commits, and it follows same-document redirects only as far as the browser takes it before the load event fires. In an authorization-code flow the identity provider issues its own 302 back to your callback route, the callback route redirects again after exchanging the code, and a single-page client may then perform a client-side route change once tokens land in memory. Playwright's auto-waiting protects a locator against a DOM that has not settled, but it cannot protect an assertion that is evaluated against the wrong origin — the identity provider's login page has no #dashboard element and never will. The fix is to make every hop an explicit, named checkpoint rather than a hope, and to run that sequence once in a setup project rather than in every spec, as described in Setting Up an Auth Setup Project.
There is a second, quieter reason these tests fail: the flow spans three cookie jars and two storage mechanisms, and only some of them survive being written to disk. The identity provider sets its own session cookie on login.idp.com, your application sets one on app.example.com, and a browser-side OIDC client stashes the PKCE code verifier and the anti-forgery state value in sessionStorage on the application origin. A test that saves state and replays it later carries the two cookies but not the sessionStorage entries, which is enough to keep the user signed in at the provider while leaving the client unable to finish an exchange. Knowing which of those artefacts is missing turns an unexplained "it logs in again every run" into a one-line fix.
Minimal reproducible example
The test below drives a real authorization-code flow end to end and saves the result. Each wait names the origin it expects, so a failure reports exactly which hop broke instead of a generic locator timeout.
import { test, expect } from '@playwright/test';
test('signs in through the identity provider and stores the session', async ({ page, context }) => {
// 1. Land on a protected route; the app immediately bounces us to the IdP.
await page.goto('/dashboard');
// 2. Checkpoint the FIRST cross-origin hop by origin, not by element.
// A regex is required here: glob patterns are resolved against baseURL.
await page.waitForURL(/login\.idp\.com\/authorize/);
// 3. The IdP page is a different app with its own DOM — use role queries.
await page.getByRole('textbox', { name: 'Username' }).fill(process.env.SSO_USER!);
await page.getByRole('textbox', { name: 'Password' }).fill(process.env.SSO_PASS!);
await page.getByRole('button', { name: 'Sign in' }).click();
// 4. Consent screens appear only on first authorization for a client id.
const consent = page.getByRole('button', { name: 'Allow access' });
if (await consent.isVisible({ timeout: 3_000 }).catch(() => false)) {
await consent.click();
}
// 5. Wait for the code exchange itself, not for the redirect that follows it.
const tokenCall = page.waitForResponse(
(r) => r.url().includes('/oauth/token') && r.status() === 200,
);
await page.waitForURL(/app\.example\.com\/callback/);
await tokenCall;
// 6. Only now is the app origin authoritative — assert on app state.
await page.waitForURL('**/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// 7. Persist cookies for BOTH domains plus localStorage for visited origins.
await context.storageState({ path: 'playwright/.auth/user.json' });
});
Step-by-step fix
- Move the whole flow into a setup project. Declare a project whose
testMatchisauth.setup.tsand make every other project list it underdependencies. The redirect chain then runs once per suite instead of once per test, which matters because identity providers rate-limit authorization requests aggressively — a 40-test suite hammering/authorizewill start receiving429responses. Project wiring is covered in Playwright Config & Fixtures. - Gate every cross-origin hop with
waitForURL()and a regular expression. String patterns passed towaitForURL()are resolved relative tobaseURL, so'**/authorize'will not match a URL on a different host in the way you expect. ARegExpsuch as/login\.idp\.com\/authorize/matches the absolute URL and reads as documentation of the expected hop. - Query the identity provider's page by role, never by class. You do not control Okta's or Entra's markup and it changes without notice, so
getByRole('textbox', { name: 'Password' })is the only selector with a stable contract. This is the same argument made in getByRole & Accessibility Selectors, and it applies doubly to third-party origins. - Treat consent and MFA screens as conditional branches. A consent screen renders only the first time a user authorizes a given client id, so an unconditional click fails on run two. Probe with
isVisible({ timeout: 3_000 })wrapped in.catch(() => false)and click only when present. For MFA, either enable a test-only policy that skips the second factor for the CI egress IP range, or generate the code from a seeded TOTP secret in your test data. - Anchor on the token exchange, not on the URL that follows it. Start
page.waitForResponse()for the/oauth/tokencall before awaiting the callback navigation, then await both. Waiting only on the callback URL leaves a window where the SPA has the authorization code but no access token, and any assertion in that window sees a loading shell. The mechanics of response waiting are detailed under Network Interception Basics. - Save cookies for both domains with
context.storageState(). The returned state contains cookies for every domain the context touched — your app and the identity provider — which is why replaying it skips the login form entirely on later runs. Reuse it by settingstorageStateinuse, exactly as in Reusing Login State with storageState. - Snapshot
sessionStorageseparately when the client is a browser-side OIDC library.storageStatepersists cookies andlocalStorageonly. Libraries such asoidc-client-tsand MSAL keep the PKCE code verifier, thestatevalue, and sometimes the access token insessionStorage, which is dropped. Read it withpage.evaluate(() => JSON.stringify(sessionStorage)), write it beside the state file, and restore it withcontext.addInitScript()before the first navigation of each test.
Troubleshooting variants
waitForURL times out on the identity provider domain
Print page.url() in the failure path before assuming the redirect never happened. Three causes dominate. The pattern is a string glob resolved against baseURL instead of a RegExp, so it silently never matches a third-party host. The identity provider served an interstitial — a tenant picker, a "Choose an account" list, or a device-trust prompt — that your regex does not cover, leaving the browser parked one hop short. Or the provider's bot protection classified the headless run and returned a challenge page; running with headless: false locally will show it immediately. A fourth, rarer cause is redirect_uri_mismatch rendered as an IdP error page, which happens when the CI baseURL differs from the registered callback and is fixed in the provider's client configuration, not in the test.
The session works locally but every CI run restarts the login flow
This is nearly always a state or nonce mismatch caused by the missing sessionStorage snapshot. The saved cookies authenticate the user at the identity provider, so the /authorize call returns a fresh code immediately, but the SPA cannot complete the exchange because the PKCE verifier that generated the challenge is gone — the console shows state mismatch or the token endpoint returns invalid_grant. Restore sessionStorage with addInitScript(), and confirm the state file is being written to a path that CI actually preserves between the setup project and the test projects rather than into a cleaned temporary directory. Artifact and cache handling for pipelines is covered under CI/CD Integration.
The provider opens the login flow in a popup window
Enterprise SSO widgets frequently call window.open() instead of navigating the top frame, and the popup is a separate Page object that your original page handle knows nothing about. Capture it with context.waitForEvent('page') started before the click, then drive the credentials form on the returned page and wait for it to emit close. The parent page finishes the exchange afterwards, so the final assertion still belongs on the original handle. Popups share the browser context, which means the cookies they receive land in the same jar that storageState() serialises — the reason Browser Contexts & Isolation is the right unit of authentication.
The flow succeeds but the very next test is signed out again
Check what scope the saved file is applied at. Setting storageState inside a test.use() block affects only the file that declares it, so any spec that omits it starts from a blank context and is bounced straight back to the provider. Declaring it at project level in use applies it to every spec the project owns, which is nearly always what you want. The other common cause is a state file written by the setup project into a path that the test projects resolve differently — an absolute path built from process.cwd() behaves differently when a shard runs from a subdirectory. Resolve the path once in the config and export it, so setup and consumption cannot drift apart.
A related symptom is a session that works for the first few specs and then evaporates. That is token lifetime, not configuration: many providers issue access tokens valid for five or fifteen minutes, and a suite longer than that will cross the boundary mid-run. If the application silently refreshes through a hidden iframe or a refresh-token call, nothing breaks; if it does not, regenerate the state per shard so no single file has to outlive the token it was built from.
Verification
Verify in four places, cheapest first. Open the saved playwright/.auth/user.json and confirm it contains cookies for two distinct domains — one for your application, one for the identity provider host. A file containing only your own domain means the provider's cookies were set on a hop the context never committed, and the next run will walk the full login form again.
Next, run a single spec with the setup project's output in place and watch the network activity: an authenticated replay should never issue a request to /authorize. If it does, the cookie was saved but rejected, usually because it was a Secure cookie and the test ran against http://localhost.
Third, force the flow to run cold by deleting the state file and running the setup project alone with --trace on. Open the resulting archive in the Playwright Trace Viewer and read the network tab top to bottom — every 302 in the chain appears in order with its Location header, which is the fastest way to spot an interstitial your script does not handle.
Finally, run the suite ten times with --repeat-each=10 against a shared setup. Authentication flakiness is intermittent by nature, and a single green run proves very little; a repeated run surfaces token-expiry races and provider throttling early. If failures concentrate at the end of long runs, the access token is expiring mid-suite and the state file needs regenerating per shard rather than per pipeline — see Configuring Retries and Timeouts for Stable CI for the surrounding timeout budget, and Testing Multiple User Roles in Parallel when each role needs its own state file.
Frequently Asked Questions
Should I mock the identity provider instead of driving the real login form?
Do both, at different layers. One setup project should exercise the genuine provider so a broken federation configuration cannot ship undetected, and everything else should replay the resulting state file. Mocking the provider entirely with route interception is reasonable for feature suites that only need a signed-in user, but it stops testing the redirect contract, which is precisely where SSO breaks — a changed callback URL, a rotated client secret, or a tightened consent policy all pass a mocked test and fail in production.
Why does my regular expression match locally but not in CI?
The tenant hostname usually differs between environments. Providers issue per-tenant subdomains such as acme.okta.com for production and acme-dev.okta.com for staging, so a hard-coded pattern matches one and times out on the other. Build the pattern from an environment variable — new RegExp(process.env.IDP_HOST + '/authorize') — and fail fast with an explicit error when that variable is unset, rather than letting a 30-second navigation timeout report the problem indirectly.
Can I skip the browser and request tokens directly from the token endpoint?
For API-only assertions, yes: a client-credentials or resource-owner grant issued through request.newContext() gives you a bearer token without a browser at all, and it is far faster. It does not give you a browser session, though — the application still expects its own cookie, and injecting a token into localStorage only works if the client reads it from there. Use the direct grant to seed API fixtures and the browser flow to establish the session the UI actually consumes.