Stubbing OAuth Redirect Flows in Playwright

The “Sign in with GitHub” button is one of the most important journeys in many applications and one of the least tested, because the real flow leaves your domain, lands on a third party’s login page, and demands credentials for a real account. Automating that page is brittle, rate-limited and often against the provider’s terms. The alternative is to stub the provider while keeping your own half of the flow real: the button, the redirect, the callback route, the state check, the session it creates. This guide covers intercepting the authorize redirect in Playwright, returning a code to your real callback, stubbing the server-side token exchange, and testing the failure paths that real providers produce. It sits under authentication and session mocking.

Root Cause Analysis

An OAuth sign-in has two halves with very different testing properties. The provider’s half — its login form, its consent screen, its account database — is not your code, changes without notice, and cannot be driven reliably by automation. Your half — generating the authorize URL with a state value, handling the callback, validating state, exchanging the code, creating a session — is entirely your code, and it is where the defects are.

Teams usually either test both halves by automating the provider, which produces a flaky suite that fails whenever the provider redesigns its page or detects automation, or test neither by seeding a session directly and never exercising sign-in at all. The first is unreliable; the second leaves the state validation, the error handling and the session creation untested.

The flow has a natural seam at each network hop. The browser’s redirect to the provider can be intercepted in the browser. The server’s code-for-token exchange can be intercepted on the server. Between those two interceptions, everything that runs is yours.

The OAuth flow and where each hop is stubbed The browser's redirect to the provider is intercepted by page.route and answered with a redirect back to the real callback carrying a code and the original state; the server's token exchange is answered by MSW; the callback, state check and session creation all run for real. sign-in button real provider authorize page.route stub /auth/callback real, checks state token exchange MSW on the server session real cookie code+state two stubs, and everything between them is your code running for real
The amber hops are the provider's; the navy ones are yours and stay real.

Reproducible Setup

The application must read the provider’s endpoints from configuration so the test environment can point them at predictable URLs.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: { baseURL: 'http://localhost:3000' },
  webServer: {
    command: 'npm run start:test',
    url: 'http://localhost:3000/api/health',
    env: {
      OAUTH_AUTHORIZE_URL: 'https://idp.test.example/authorize',
      OAUTH_TOKEN_URL: 'https://idp.test.example/token',
      OAUTH_USERINFO_URL: 'https://idp.test.example/userinfo',
      OAUTH_CLIENT_ID: 'test-client',
      OAUTH_CLIENT_SECRET: 'test-secret',
      ENABLE_MSW_SERVER: '1',
    },
  },
});
// src/instrumentation.ts — the server-side stub, loaded only when the build enables it
export async function register() {
  if (process.env.ENABLE_MSW_SERVER === '1' && process.env.NEXT_RUNTIME === 'nodejs') {
    const { server } = await import('../test/msw/idp-server');
    server.listen({ onUnhandledRequest: 'bypass' });
  }
}

Implementation

Step 1 — Intercept the authorize redirect and bounce back with a code. The stub reads the state and redirect_uri from the authorize request and sends the browser straight back, exactly as a provider would after a successful login.

// e2e/fixtures/idp.ts
import type { Page } from '@playwright/test';

export async function stubAuthorize(page: Page, { code = 'test-code', error }: { code?: string; error?: string } = {}) {
  await page.route('https://idp.test.example/authorize**', async (route) => {
    const url = new URL(route.request().url());
    const redirect = new URL(url.searchParams.get('redirect_uri')!);
    redirect.searchParams.set('state', url.searchParams.get('state')!);
    if (error) redirect.searchParams.set('error', error);
    else redirect.searchParams.set('code', code);
    await route.fulfill({ status: 302, headers: { location: redirect.toString() } });
  });
}

Step 2 — Stub the token and userinfo endpoints on the server. The callback route exchanges the code server-side, which Playwright cannot see; MSW running inside the application process answers it.

// test/msw/idp-server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

export const server = setupServer(
  http.post('https://idp.test.example/token', async ({ request }) => {
    const body = new URLSearchParams(await request.text());
    if (body.get('code') !== 'test-code') return HttpResponse.json({ error: 'invalid_grant' }, { status: 400 });
    return HttpResponse.json({ access_token: 'at_test', token_type: 'Bearer', expires_in: 3600 });
  }),
  http.get('https://idp.test.example/userinfo', () =>
    HttpResponse.json({ sub: 'idp|42', email: 'ada@example.test', name: 'Ada Lovelace' }),
  ),
);

Step 3 — Write the happy-path journey. Click the real button, let the stubs answer, and assert on the signed-in state your application produces.

// e2e/journeys/sign-in.spec.ts
import { test, expect } from '@playwright/test';
import { stubAuthorize } from '../fixtures/idp';

test.use({ storageState: { cookies: [], origins: [] } });

test('a visitor signs in with the identity provider', async ({ page }) => {
  await stubAuthorize(page);
  await page.goto('/');
  await page.getByRole('button', { name: 'Sign in with Acme ID' }).click();

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('button', { name: 'Ada Lovelace' })).toBeVisible();
});

Step 4 — Test the refusal path. Users cancel at the consent screen far more often than teams expect, and the provider sends an error parameter back to the callback.

test('a visitor who cancels at the provider sees a clear message', async ({ page }) => {
  await stubAuthorize(page, { error: 'access_denied' });
  await page.goto('/');
  await page.getByRole('button', { name: 'Sign in with Acme ID' }).click();

  await expect(page).toHaveURL(/\/sign-in/);
  await expect(page.getByRole('alert')).toHaveText('Sign-in was cancelled. You can try again.');
});
Failure paths worth one test each A cancelled consent, a tampered state value, a rejected code at the token endpoint and a provider outage each exercise different code in your callback, and each can be produced by changing one stub. Scenario How to produce it user cancels consent stubAuthorize({ error: 'access_denied' }) state tampered or replayed return a different state in the redirect code rejected at exchange stubAuthorize({ code: 'expired' }) provider unavailable token handler returns 503
Each failure is one changed stub, and each exercises a branch of your callback that a real provider rarely lets you reach on demand.

Step 5 — Test state validation explicitly. This is the security-relevant check, and the one most often broken silently by a refactor.

test('a callback with a mismatched state is refused', async ({ page }) => {
  await page.route('https://idp.test.example/authorize**', async (route) => {
    const redirect = new URL(new URL(route.request().url()).searchParams.get('redirect_uri')!);
    redirect.searchParams.set('state', 'forged-state');
    redirect.searchParams.set('code', 'test-code');
    await route.fulfill({ status: 302, headers: { location: redirect.toString() } });
  });
  await page.goto('/');
  await page.getByRole('button', { name: 'Sign in with Acme ID' }).click();
  await expect(page.getByRole('alert')).toContainText('could not be verified');
  await expect(page.context().cookies()).resolves.not.toContainEqual(expect.objectContaining({ name: 'session' }));
});

This test earns its place for a specific reason. The state parameter exists to stop an attacker completing a sign-in on a victim’s behalf, and the check that enforces it is usually a single comparison buried in a callback handler. It is the kind of line that a refactor can remove without any other test noticing, because every happy-path test supplies a matching state by construction. A deliberately forged state is the only input that exercises the rejection branch.

Step 6 — Keep this journey singular. Sign-in deserves a handful of tests; every other journey should start signed in through seeded storage state, as described in reusing authenticated state across Playwright tests.

A small addition worth making once the suite is stable: record the authorize URL the application generated and assert on its parameters — the scope, the response type, the presence of a code challenge if you use PKCE. The stub sees that URL anyway, and a regression that drops a requested scope or the challenge is invisible from the signed-in outcome alone.

Verification

Confirm the real callback ran, not a shortcut. The session cookie should exist after sign-in and should have the attributes your production configuration sets.

const cookies = await page.context().cookies();
expect(cookies.find((c) => c.name === 'session')).toMatchObject({ httpOnly: true, sameSite: 'Lax', path: '/' });

Then confirm the stubs are the only reason the test passes by removing one. Delete the page.route stub and run the test: it should fail trying to reach idp.test.example, proving no real provider is involved and no hidden bypass exists.

npx playwright test e2e/journeys/sign-in.spec.ts --grep "signs in"
# with the stub removed:
# Error: page.goto: net::ERR_NAME_NOT_RESOLVED at https://idp.test.example/authorize?...
Checks that the stubbed flow is honest The session cookie carries production attributes, removing a stub makes the test fail at the provider hop, and the server-side stub is absent from the production build. real cookie httpOnly, sameSite, path as production sets them stub is load-bearing remove it and the test fails at the provider not in production the MSW import is gated by a build-time flag
The third check is the one that protects production; the first two protect the test's meaning.

Troubleshooting

Symptom: the browser navigates to the real provider despite the route. Diagnosis: the route pattern does not match because the authorize URL carries a different host or path than expected, often a trailing slash or a region subdomain. Fix: log route.request().url() from a catch-all route once, then write the pattern against what the application actually requests.

Symptom: the callback fails with “invalid_grant”. Diagnosis: the server-side stub is not running, so the application reaches the real token endpoint, or the code the browser stub returns does not match what the server stub accepts. Fix: confirm the server-side interceptor starts by logging from it, and keep the test code in one shared constant used by both stubs.

Symptom: the test passes locally and fails in CI with a state mismatch. Diagnosis: state is stored in a cookie scoped to one host, and CI serves the application on a different one — 127.0.0.1 rather than localhost. Fix: use one canonical base URL everywhere, and make the callback’s redirect URI derive from it rather than from a hard-coded value.

Symptom: the MSW server stub ends up in a production bundle. Diagnosis: it is imported unconditionally and only disabled at runtime. Fix: gate the import on a build-time flag and verify its absence in CI by searching the production output for the stub’s hostname.

FAQ

Is intercepting in the browser enough on its own?

No, because the code-for-token exchange happens server-side, where Playwright’s routing cannot reach. That is why the setup has two stubs. If your application uses a pure browser-side flow such as PKCE without a backend exchange, the browser stub alone is sufficient.

Should the real provider ever be exercised?

A single scheduled test against the real provider in a dedicated environment is reasonable insurance against configuration drift — a changed redirect URI, a rotated client secret. It should not gate merges, because its failures are usually about the provider or the account rather than your code.

How does this relate to NextAuth?

Auth.js providers read the same authorize, token and userinfo endpoints, so the same two stubs work — point the provider’s configuration at the test URLs. Everything after sign-in is covered more cheaply at the unit and integration tiers, as described in testing NextAuth-protected routes.

What about providers that use OpenID Connect discovery?

Stub the discovery document as well — /.well-known/openid-configuration — returning your test endpoints and a JWKS URL. That keeps the application’s discovery logic in the test path, which is worth having because a discovery misconfiguration fails every sign-in at once.