Skip to content

Testing Auth Flows Without Real Auth

How to E2E auth-gated journeys without hitting a real IdP every run, storage state, route stubs, and the lies that still slip through.

4 min read
Playwright
Auth
E2E Testing
Storage State
Frontend
Security

Page objects make login clickable. They do not answer the CI question: do you mint a real session against the IdP on every run?

Usually you should not. Real auth in E2E is slow, flaky, and often blocked by bot protection. Fake auth that never touches cookies or redirects is how you ship a green suite that cannot see the bug. I use a middle path: honest enough to catch gate failures, fake enough to stay in CI budgets.

Three layers of "not real auth"

LayerWhat you fakeWhat stays realUse when
Storage stateHow the session was obtainedCookie/header shape the app readsHappy-path journeys after login
Route stubIdP / token endpointsApp routing + client gate logicError and edge paths
Test-only bypassThe gate itselfAlmost nothing about authLocal smoke only, never as sole CI proof

Most suites need (1) for the bulk of specs and a thin (2) for failure modes. (3) is a sharp tool; treat it like dangerouslySetInnerHTML.

Storage state: mint once, reuse often

Playwright’s storage state lets one setup project sign in (or seed cookies) and every dependent project reuse the jar.

ts
// e2e/auth.setup.ts, illustrative import { test as setup, expect } from '@playwright/test'; import path from 'node:path'; import { LoginPage } from './pages/login.page'; const authFile = path.join(__dirname, '../.auth/user.json'); setup('authenticate', async ({ page }) => { const login = new LoginPage(page); await login.goto(); // Prefer a dedicated test user + test tenant, never a human’s prod account. await login.signIn( process.env.E2E_USER_EMAIL!, process.env.E2E_USER_PASSWORD!, ); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); await page.context().storageState({ path: authFile }); });
ts
// playwright.config.ts, illustrative excerpt export default defineConfig({ projects: [ { name: 'setup', testMatch: /auth\.setup\.ts/ }, { name: 'chromium', dependencies: ['setup'], use: { storageState: 'e2e/.auth/user.json' }, }, ], });

Decision criteria: if obtaining the session needs a real IdP once per CI shard, pay that cost in setup, not in every spec. If even once-per-shard is impossible (no test tenant, CAPTCHA), seed a signed test cookie from a backend test helper you own, with the same claims shape production uses.

Stub the IdP; do not stub the gate

When you need "expired token" or "user lacks role," stub the network, keep the client’s redirect and empty-state behavior real.

ts
// illustrative, expire mid-journey await page.route('**/api/me', async (route) => { await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: 'unauthenticated' }), }); }); await page.goto('/orders'); await expect(page).toHaveURL(/\/login/);

What I refuse: a helper that sets window.__BYPASS_AUTH__ = true and then claims the suite proved the gate. That proves the bypass flag works.

Secrets and fixtures

  • Store E2E credentials in CI secrets; never commit them next to storage state.
  • Add .auth/ to .gitignore. Committed session files are leaked sessions.
  • Rotate test users when someone leaves; shared passwords become tribal knowledge.
  • Prefer role-specific storage states (admin.json, viewer.json) over one god user that can do everything, role bugs hide behind overpowered fixtures.

Auth bugs love deep links: user hits /orders/123 logged out, signs in, and lands on / instead of the order. Storage-state suites skip that path by construction. Keep one cold-start spec:

ts
// illustrative, no preloaded storageState for this project/spec test.use({ storageState: { cookies: [], origins: [] } }); test('returns to deep link after login', async ({ page }) => { await page.goto('/orders/ord_123'); await expect(page).toHaveURL(/\/login/); const login = new LoginPage(page); await login.signIn( process.env.E2E_USER_EMAIL!, process.env.E2E_USER_PASSWORD!, ); await expect(page).toHaveURL(/\/orders\/ord_123/); });

That single journey earns more trust than twenty dashboard screenshots of an already-authed shell.

Failure modes

Storage state forever. Sessions expire; CI goes red on Monday. Refresh in setup, or fail setup loudly when /api/me is 401 instead of letting every spec time out.

Production IdP in CI. Bot walls, MFA prompts, rate limits. Use a test tenant or a signed cookie helper. If neither exists, that is a platform gap - escalate it; do not paper over with sleeps.

Testing only the happy cookie. You never assert logged-out → login → return URL. Add one thin journey that starts cold; keep the rest on storage state.

Auth page object that knows production passwords. Credentials belong in env; objects accept parameters. The page object pattern from the prior post still applies.

Continuity

Storage state and route stubs are the tools. Year-end tooling judgment across lint, types, and test layers follows.

Takeaway

Reuse storage state for happy paths and stub IdP edges, but never let a test-only bypass be the only proof your auth gate still works.