lecture 5 of 170 completed

Authentication architecture at scale

storageState files, setup projects, several roles and token expiry. Auth design decides both runtime and reliability.

Six roles, two thousand tests, and a token that expires after thirty minutes while the suite runs for forty. This lecture is the architecture that handles all three.

The mechanism. storageState is a JSON file holding cookies and localStorage. Save it once, and any context can start from it already authenticated.

The setup project pattern, which is the piece that makes this scale:

// playwright.config.ts
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },

  {
    name: 'chromium-admin',
    use: { ...devices['Desktop Chrome'], storageState: '.auth/admin.json' },
    dependencies: ['setup'],
  },
  {
    name: 'chromium-standard',
    use: { ...devices['Desktop Chrome'], storageState: '.auth/standard.json' },
    dependencies: ['setup'],
  },
],

dependencies: ['setup'] means Playwright runs the setup project first, every time, before any dependent project starts. That is the correct place for authentication: once per run, not once per test or per worker.

The setup file itself:

// tests/auth.setup.ts
import { test as setup } from '@playwright/test';

const ROLES = ['admin', 'standard', 'readonly', 'finance', 'support', 'auditor'];

for (const role of ROLES) {
  setup(`authenticate as ${role}`, async ({ request }) => {
    const response = await request.post('/api/login', { data: CREDENTIALS[role] });
    const { token } = await response.json();

    await request.storageState({ path: `.auth/${role}.json` });
  });
}

Six logins for the whole run, regardless of test count.

Authenticate through the API, not the UI. Same reasoning as the Cypress track: these tests are not testing login, they need a logged-in user. Keep one real UI login test that exercises the form properly and let everything else take the fast path.

Token expiry, the problem most teams hit at scale.

A token issued at the start of a 45-minute run has expired by the time later tests use it. The state file is still there, so nothing looks wrong, and you get a wave of unexplained failures in the second half of the suite.

Three defences, in order of preference:

Use long-lived test tokens. If your identity provider can issue tokens valid for hours in non-production environments, this removes the problem entirely. Ask for it; it is a reasonable request.

Re-authenticate per worker. A worker-scoped fixture that refreshes if the token is close to expiry. More machinery, and it works when you cannot change token lifetime.

Validate before use. A cheap authenticated API call at the start; if it fails, re-authenticate. This is the same validate idea Cypress's cy.session builds in, done by hand.

The file-per-role decision. One file per role, not one file per user, unless tests mutate the user. A test that changes a user's settings and shares a state file with two hundred others has created a shared-state bug that appears only in certain orderings.

If a test must mutate its user, give it a dedicated account created in setup and never reused. The extra account is cheaper than the debugging.

Do not commit .auth. Add it to .gitignore on day one. These files contain live session tokens. I have seen them committed, and the rotation conversation afterwards is not pleasant.

When roles multiply beyond projects. Six roles as six projects means the whole suite runs six times, which is usually wrong. Most tests need one role. Use projects for the roles you want full coverage under, and for everything else load the state file inside a fixture:

authedPageAs: async ({ browser }, use) => {
  await use(async (role: string) => {
    const context = await browser.newContext({ storageState: `.auth/${role}.json` });
    return context.newPage();
  });
},

Now a single test can act as any role without multiplying the run.

Remember this: setup project for the logins, one state file per role, plan for expiry, and never let a mutating test share a state file.

Reference: Authentication.