e2e-auth-flow
E2E Auth Flow Skill
Discovery
Before writing tests, identify:
- Framework — Playwright (preferred) or Cypress; auth storage APIs differ significantly
- Auth mechanism — session cookie, JWT in localStorage, JWT in httpOnly cookie, OAuth/SSO
- Token storage location — determines how to capture and reuse state (
localStorage,sessionStorage,httpOnlycookie) - Protected routes — which pages require auth, which redirect to login when unauthenticated
- Email flows — does password reset require real email interception? (needs Mailhog, Mailtrap, or
+aliastrick) - Test user strategy — seeded DB user, or register fresh each run?
Session Reuse — The Core Pattern
Re-logging in before every test is the most common E2E auth mistake. It's slow and it couples every test to the login flow. Instead, authenticate once and save state:
Playwright
// auth.setup.ts — runs once before the test suite
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.fill('[name=email]', process.env.TEST_USER_EMAIL!);
await page.fill('[name=password]', process.env.TEST_USER_PASSWORD!);
await page.click('[type=submit]');
await page.waitForURL('/dashboard');
// Save full browser state: cookies + localStorage + sessionStorage
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'authenticated',
dependencies: ['setup'],
use: { storageState: 'playwright/.auth/user.json' },
},
],
});
Cypress
// cypress/support/commands.ts
Cypress.Commands.add('loginByApi', () => {
cy.request('POST', '/api/auth/login', {
email: Cypress.env('TEST_USER_EMAIL'),
password: Cypress.env('TEST_USER_PASSWORD'),
}).then(({ body }) => {
// Store token directly — skip the UI entirely
window.localStorage.setItem('auth_token', body.token);
// or cy.setCookie for cookie-based auth
});
});
// In tests — never use cy.visit('/login') + fill form
beforeEach(() => cy.loginByApi());
API-based login in Cypress is 10-50x faster than UI login and doesn't flake on form animations.
What to Test
1. Registration — assert the full side effect chain, not just redirect
test('registration creates account and sends verification email', async ({ page }) => {
await page.goto('/register');
await page.fill('[name=email]', uniqueEmail()); // generate unique to avoid conflicts
await page.fill('[name=password]', 'SecurePass123!');
await page.click('[type=submit]');
// Assert redirect
await expect(page).toHaveURL('/verify-email');
// Assert DB side effect (if test DB accessible)
const user = await db.findByEmail(email);
expect(user.emailVerified).toBe(false);
// Assert email was queued (check Mailhog or mock email service)
const emails = await mailhog.getMessages({ to: email });
expect(emails).toHaveLength(1);
expect(emails[0].subject).toContain('Verify');
});
A test that only checks the redirect proves nothing about whether the account was actually created.
2. Login — test session persistence across page loads
test('session persists after page reload', async ({ page }) => {
await login(page);
await page.reload();
// Must still be on authenticated page, not redirected to /login
await expect(page).not.toHaveURL('/login');
await expect(page.locator('[data-testid=user-menu]')).toBeVisible();
});
This catches bugs where auth state is stored in memory only and lost on reload.
3. Protected routes — test both the redirect AND the return
test('unauthenticated user is redirected to login with return URL', async ({ page }) => {
// Access protected route without auth
await page.goto('/dashboard/settings');
await expect(page).toHaveURL(/\/login\?.*redirect/);
// After login, must return to original destination
await page.fill('[name=email]', testUser.email);
await page.fill('[name=password]', testUser.password);
await page.click('[type=submit]');
await expect(page).toHaveURL('/dashboard/settings'); // not just /dashboard
});
Testing only the redirect misses the broken return URL — a very common bug.
4. Logout — assert session is actually destroyed, not just UI reset
test('logout invalidates session on server', async ({ page, request }) => {
const storageState = await page.context().storageState();
const cookies = storageState.cookies;
await page.click('[data-testid=logout-button]');
await expect(page).toHaveURL('/login');
// Attempt API call with the old session cookie — must be rejected
const res = await request.get('/api/me', {
headers: { Cookie: cookies.map(c => `${c.name}=${c.value}`).join('; ') }
});
expect(res.status()).toBe(401); // session was actually invalidated server-side
});
Without this, a "logout" that only clears client state still leaves a valid session exploitable by anyone with the cookie.
5. Password reset — test token expiry, not just the happy path
// Happy path
test('valid reset token allows password change', async ({ page }) => {
const token = await db.createPasswordResetToken(testUser.id, { expiresIn: '1h' });
await page.goto(`/reset-password?token=${token}`);
await page.fill('[name=password]', 'NewSecurePass456!');
await page.click('[type=submit]');
await expect(page).toHaveURL('/login');
// Verify old password no longer works
await loginWith(page, testUser.email, testUser.originalPassword);
await expect(page).toHaveURL('/login'); // rejected
});
// Expired token
test('expired reset token shows error, not silent failure', async ({ page }) => {
const token = await db.createPasswordResetToken(testUser.id, { expiresAt: pastDate() });
await page.goto(`/reset-password?token=${token}`);
await expect(page.locator('[data-testid=error]')).toContainText(/expired|invalid/i);
});
// Token reuse
test('reset token cannot be used twice', async ({ page }) => {
const token = await db.createPasswordResetToken(testUser.id);
await useResetToken(page, token, 'FirstNewPass123!');
await page.goto(`/reset-password?token=${token}`);
await expect(page.locator('[data-testid=error]')).toContainText(/expired|invalid/i);
});
6. Token refresh — test the silent refresh, not just initial auth
For JWT-based auth with refresh tokens:
test('expired access token is silently refreshed', async ({ page }) => {
await login(page);
// Expire the access token in storage without touching the refresh token
await page.evaluate(() => {
const auth = JSON.parse(localStorage.getItem('auth')!);
auth.accessToken = 'expired.token.value';
localStorage.setItem('auth', JSON.stringify(auth));
});
// Navigate to a protected page — should silently refresh, not redirect to login
await page.goto('/dashboard');
await expect(page).not.toHaveURL('/login');
await expect(page.locator('[data-testid=user-menu]')).toBeVisible();
});
7. Concurrent sessions — test multi-tab behavior if relevant
test('logout in one tab invalidates other open sessions', async ({ browser }) => {
const context = await browser.newContext({ storageState: authState });
const tab1 = await context.newPage();
const tab2 = await context.newPage();
await tab1.goto('/dashboard');
await tab2.goto('/dashboard');
// Logout in tab1
await tab1.click('[data-testid=logout-button]');
// Navigate in tab2 — should be kicked out
await tab2.goto('/dashboard');
await expect(tab2).toHaveURL('/login');
});
Test User Strategy
Seed once, reuse — don't register via UI in every suite
// global-setup.ts
export default async function globalSetup() {
await db.upsert('users', {
email: process.env.TEST_USER_EMAIL,
passwordHash: await hash(process.env.TEST_USER_PASSWORD),
emailVerified: true,
});
}
Registration via UI is for testing the registration flow specifically — not a setup utility.
Use unique emails for registration tests
const uniqueEmail = () => `test+${Date.now()}@example.com`;
// or
const uniqueEmail = () => `test+${randomUUID()}@example.com`;
Static test emails in registration tests cause conflicts when tests run in parallel or are re-run without DB cleanup.
What Not to Do
- Don't use UI login in
beforeEach— save and reuse storage state; UI login in setup is only for the auth setup step itself - Don't hardcode credentials — always use
process.env/Cypress.env; never commit test passwords - Don't test only the redirect on protected routes — test the return URL too
- Don't trust client-side logout — always verify the session is invalidated server-side with a raw API call
- Don't skip token expiry and reuse tests for password reset — these are the cases attackers actually target
- Don't use
page.waitForTimeout— wait for URL, element visibility, or network idle instead; timeouts are flaky
Output Format
Group tests into four describe blocks: registration, login/session, protected routes, and account recovery (password reset). Auth setup lives in a dedicated setup file, not in beforeEach. Each test is independent of execution order — no test should depend on another test having run first.
More from blunotech-dev/agents
anti-purple-ui
Enforce a strict monochrome UI with a single high-contrast accent color, removing generic tech gradients and “AI-style” palettes. Use when the user wants minimal, anti-AI, or non-generic aesthetics, or says the UI looks too techy or generic.
9harmonize-whitespace
Align all spacing (padding, margins, gaps) to a consistent 4pt/8pt grid. Use when spacing feels off, inconsistent, cramped, or unbalanced, or when the user asks for a spacing scale or alignment fix.
9typographic-hierarchy
Improve typography by adjusting font sizes, weights, spacing, and contrast to create clear visual hierarchy and readability. Use when text feels flat, unstructured, or when the user asks to refine headings, type scale, or overall readability.
6micro-interaction-adder
Add polished CSS micro-interactions like hover effects, transitions, and feedback states to improve UI feel. Use when the user asks for animations, better UX, or when the interface feels static, plain, or unresponsive.
4consistent-border-radius
Normalizes rounded corners across a file so buttons, inputs, cards, modals, badges, and all UI elements share the exact same curvature. Use this skill whenever the user mentions inconsistent border radii, wants to unify rounded corners, asks to make UI elements look more cohesive, or says things like "make the corners match", "fix the rounding", "unify border radius", "standardize my rounded corners", or "buttons and cards don't match". Also trigger when the user pastes a CSS/HTML/JSX/TSX file and asks for a design consistency pass, border radius is one of the first things to normalize.
4component-split
Analyze a component and determine when and how to split it based on size, responsibility, and reuse signals, producing a refactored structure with clear boundaries. Use when users share large, mixed-concern, or hard-to-test components, or ask about splitting, refactoring, or improving component architecture.
3