Auth is the first thing every user hits and the first thing every test suite gets wrong. Most flaky end-to-end suites are flaky because of how they handle login: they re-authenticate in every test, share one account across parallel workers, or drive a third-party OAuth screen that was never built for automation. Here are the patterns that keep auth tests fast and honest.
Use dedicated test identities, never real ones
Give your suite its own accounts. Not a developer's personal login, not a shared admin, not a customer record you found in staging. A dedicated test identity has three properties that matter:
- It's disposable. You can reset its data, wipe its cart, or let a test corrupt its state without paging anyone.
- It's scoped. Give it exactly the permissions the flow under test needs. A test that exercises the checkout page shouldn't run as a superuser.
- It's isolated per concern. If two suites need different states — one "empty" account, one "account with 200 orders" — make them two identities, not one you keep mutating.
The credentials for those identities are secrets. They belong in your CI secret store or an environment variable, never hardcoded in a spec and never committed. In Playwright, read them at runtime:
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
Session reuse versus fresh login per test
The single biggest lever on suite speed is not logging in for every test. Logging in is slow, it's network-bound, and it exercises the same three fields a thousand times to tell you nothing new. Authenticate once, save the resulting session, and reuse it.
Playwright models this cleanly with a setup project that logs in and writes storageState (cookies plus localStorage) to disk. Every other project depends on it and starts already authenticated.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup'],
},
],
});
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for something that only exists once you're in.
await expect(page.getByRole('button', { name: 'Account' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
Now every test in chromium opens with a live session and skips the login form entirely.
The one place you should still log in for real is the login test itself. Keep a small, explicit suite that fills the form, checks bad-password errors, verifies the redirect, and confirms logout clears the session. Force that suite to start signed-out so it's testing what it claims to:
test.use({ storageState: { cookies: [], origins: [] } });
The rule of thumb: one test proves login works; every other test assumes it does.
Handle expiry and refresh deliberately
Sessions end. Tokens expire, refresh tokens rotate, and "remember me" changes the rules. If you never test the boundary, you'll ship the bug where an expired session dumps the user on a blank page instead of the login screen.
Test the transition, not the clock. Clear the session mid-flow and assert the app recovers:
test('expired session sends the user back to login', async ({ page, context }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Simulate the session dying between requests.
await context.clearCookies();
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.getByText('Please sign in to continue')).toBeVisible();
});
If you use short-lived access tokens with silent refresh, add a test that waits past the access-token lifetime and confirms an in-page action still succeeds — that's the difference between a working refresh loop and a silent logout your users will hate. Keep token lifetimes configurable in your test environment so this test takes seconds, not the production 15 minutes.
OAuth in tests: don't drive the provider
The "Continue with Google" and "Continue with GitHub" buttons are a trap for browser automation. The provider's consent screen is designed to resist bots: it changes markup without notice, throws CAPTCHAs and device checks, and enforces MFA you can't script. A test that clicks through the real Google login is slow, flaky, and one UI redesign away from red.
Two patterns keep you sane:
- Bypass the provider for functional tests. Seed the authenticated session directly — via a test-only login endpoint, an API call that mints a session, or a captured
storageState— so your app opens already signed in. Your tests then exercise your app, which is what you actually own. - Keep one real smoke test, isolated and allowed to be slow. If you must prove the OAuth round-trip works end to end, use a provider that supports test accounts, run it separately from the main suite, and don't let it gate every deploy. Accept that it will occasionally need a human.
The principle: test your authorization logic (what a logged-in user can see and do), not the identity provider's login screen. That screen is their job to test.
Lockout traps that quietly break suites
Auth systems fight back against exactly the traffic a test suite generates, and the failures look like flakiness:
- Rate limits and lockout. Your login test deliberately submits wrong passwords — and trips whatever lockout rule your app enforces, breaking every later test using that identity. Fix: give the negative-path tests their own throwaway identity, separate from the one your happy-path suite reuses.
- Parallel workers on one account. Run four workers that each log in as
test@example.comand the backend may invalidate older sessions when a new one appears, or hit per-account concurrency caps. Either pool one identity per worker, or authenticate once and share the savedstorageState(which sidesteps re-login entirely). - CAPTCHA after N attempts. If a bot check appears mid-suite, don't try to solve it. Whitelist your test environment's IPs or disable the challenge for known test identities in non-production configs.
- Email and SMS verification. Real inboxes and phones don't belong in a fast suite. Use a mailbox API or a test-only bypass for the codes, and keep the true end-to-end verification as a rare, separate check.
The common thread is separation: negative tests, parallel workers, and verification flows each want their own isolated identity so one test's side effect never poisons another's.
Where TestVibe fits
When you describe an auth flow in plain language, TestVibe generates the Playwright test by driving a live browser session against your app, then you run that generated test to see it pass — the behavior is verified by execution, not asserted on faith. Credentials stay out of the test text: store them in your project's Variables & Secrets and reference them from Gherkin with {{secret:TEST_USER_PASSWORD}} tokens that resolve at run time and never appear in the feature file or run logs.

Because a signed-in flow is reused constantly, you can model login once as a prerequisite feature and let dependent tests assume it instead of repeating the sign-in steps everywhere.
Auth testing rewards discipline over cleverness: dedicated identities, log in once, test the expiry boundary on purpose, and keep the third-party provider out of your critical path. Get those four right and most of the flakiness that plagues auth-heavy suites goes away. Get early access to try it against your own login, or read more in the TestVibe docs.