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"
| Layer | What you fake | What stays real | Use when |
|---|---|---|---|
| Storage state | How the session was obtained | Cookie/header shape the app reads | Happy-path journeys after login |
| Route stub | IdP / token endpoints | App routing + client gate logic | Error and edge paths |
| Test-only bypass | The gate itself | Almost nothing about auth | Local 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.
Return URLs and deep links
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.