PRACTICAL GUIDE / playwright multi tenant authentication architecture

Multi-Tenant Playwright Authentication Architecture for Parallel CI

Build tenant-safe Playwright authentication with worker account leases, isolated storage state, parallel CI identity, negative guards, and crash recovery.

By The Testing AcademyUpdated July 11, 20269 min read
All field guides
In this guide9 sections
  1. Begin with a tenant isolation threat model
  2. Define a globally unique lease owner
  3. Put policy in the credential broker
  4. Authenticate once per worker and isolate every test
  5. Treat storage state as scoped credential material
  6. Prove positive identity before business actions
  7. Add explicit cross-tenant guard scenarios
  8. Bound retries, expiry, and account mutation
  9. Recover from crashes without weakening isolation

What you will learn

  • Begin with a tenant isolation threat model
  • Define a globally unique lease owner
  • Put policy in the credential broker
  • Authenticate once per worker and isolate every test

Multi-tenant authentication tests can pass every login assertion while still being architecturally unsafe. The real contract is that each worker receives the intended tenant and role, no concurrent worker mutates through the same identity, authenticated state never crosses tenant boundaries, and a failed process cannot poison the account pool.

Build that contract around a credential broker and stable lease identity. Storage state is an output of the lease, not the source of truth. Every browser context remains isolated, and dedicated negative scenarios prove the server rejects cross-tenant access.

Begin with a tenant isolation threat model

Name the boundaries before choosing fixtures. Tenant identity may come from hostname, path, token claims, selected workspace, or a combination. Role identity may be tenant-local. An account that is an administrator in tenant A might have no membership in tenant B. The fixture must not infer these relationships from display text.

The Playwright authentication guide recommends one account per parallel worker when tests modify server-side state and warns that stored browser state can contain credentials capable of impersonation. In a multi-tenant suite, apply both rules together: lease by tenant and role, then protect the resulting state as a secret.

Animated field map

Tenant-Safe Authentication Flow

A broker assigns an exclusive tenant identity, the worker authenticates once, each test opens an isolated context, and negative checks enforce the boundary.

  1. 01 / credential broker

    Tenant credential broker

    Select an eligible account for a precise tenant, role, and run owner.

  2. 02 / worker lease

    Worker account lease

    Reserve the identity atomically with expiry and idempotent ownership.

  3. 03 / tenant state

    Tenant storage state

    Authenticate cleanly and store protected state under run output.

  4. 04 / isolated context

    Isolated browser context

    Bootstrap a fresh context for every test from the leased identity.

  5. 05 / tenant guard

    Cross-tenant guard

    Assert foreign identifiers are denied without leaking data.

Define a globally unique lease owner

Playwright's parallelIndex remains stable when a failed worker is replaced, while workerIndex changes. The parallel index is still local to one runner invocation. Build the owner from a pipeline run ID, shard ID, project name, and parallel slot. Include tenant and role in the requested resource, not merely in a log label.

Do not generate the run ID independently inside each worker. CI should provide one value to all shard jobs. Reject a missing run ID in shared environments; silently falling back to local can make unrelated jobs claim the same lease.

The broker should treat repeated acquisition by the same owner as idempotent. A replacement worker can then recover the existing account instead of consuming another. If the requested tenant or role differs for an existing owner, return a conflict rather than mutating the lease in place.

Put policy in the credential broker

The broker, not the Playwright test, should decide which account is eligible. Its acquire operation needs tenant, role, owner, and expiry inputs. Its response can return a short-lived credential or an account reference resolved through a secret service. Never log passwords, session tokens, or complete storage state.

Atomicity belongs in the broker's storage layer. Two workers requesting the same role must not both receive one account after reading it as available. Enforce exclusivity through a transaction, conditional update, or unique active-lease constraint. A static JSON array indexed by worker is acceptable only when one runner exclusively owns that array.

Make capacity failure explicit. Returning "no editor account available for tenant north" is actionable. Falling back to an admin account expands privileges and invalidates authorization coverage.

Authenticate once per worker and isolate every test

Use a worker-scoped fixture for the expensive account lease and login, then override storageState so Playwright's normal test-scoped context starts fresh from that state. Resolve tenant and role from trusted project metadata in this example; a typed worker option is another valid design.

TypeScript
// test-support/tenant-test.ts
import fs from 'node:fs/promises';
import path from 'node:path';
import { test as base, expect } from '@playwright/test';

type TenantSession = {
  leaseId: string;
  tenantKey: string;
  accountId: string;
  storageStatePath: string;
};

type WorkerFixtures = { tenantSession: TenantSession };

export const test = base.extend<{}, WorkerFixtures>({
  tenantSession: [async ({ browser, playwright }, use, workerInfo) => {
    const runId = process.env.QA_RUN_ID;
    const shard = process.env.QA_SHARD_ID ?? 'local';
    const brokerToken = process.env.QA_BROKER_TOKEN;
    const tenantKey = String(workerInfo.project.metadata.tenantKey ?? '');
    const role = String(workerInfo.project.metadata.role ?? '');
    const baseURL = workerInfo.project.use.baseURL;

    if (!runId || !brokerToken || !tenantKey || !role || typeof baseURL !== 'string') {
      throw new Error('Tenant authentication metadata is incomplete');
    }

    const owner = `${runId}:${shard}:${workerInfo.project.name}:${workerInfo.parallelIndex}`;
    const broker = await playwright.request.newContext({
      baseURL,
      extraHTTPHeaders: { Authorization: `Bearer ${brokerToken}` },
    });

    const acquired = await broker.post('/api/test-support/account-leases', {
      data: { owner, tenantKey, role },
    });
    expect(acquired.ok()).toBeTruthy();
    const lease = await acquired.json() as {
      leaseId: string;
      accountId: string;
      email: string;
      password: string;
    };

    const storageStatePath = path.join(
      workerInfo.project.outputDir,
      '.auth',
      `${workerInfo.parallelIndex}.json`,
    );
    await fs.mkdir(path.dirname(storageStatePath), { recursive: true });

    const loginPage = await browser.newPage({ storageState: undefined });
    await loginPage.goto(`${baseURL}/sign-in`);
    await loginPage.getByLabel('Tenant').fill(tenantKey);
    await loginPage.getByLabel('Email').fill(lease.email);
    await loginPage.getByLabel('Password').fill(lease.password);
    await loginPage.getByRole('button', { name: 'Sign in' }).click();
    await expect(loginPage.getByTestId('active-tenant')).toHaveText(tenantKey);
    await loginPage.context().storageState({ path: storageStatePath });
    await loginPage.close();

    try {
      await use({
        leaseId: lease.leaseId,
        tenantKey,
        accountId: lease.accountId,
        storageStatePath,
      });
    } finally {
      await broker.delete(`/api/test-support/account-leases/${lease.leaseId}`);
      await broker.dispose();
    }
  }, { scope: 'worker' }],

  storageState: async ({ tenantSession }, use) => {
    await use(tenantSession.storageStatePath);
  },
});

export { expect };

The release endpoint should be idempotent and authenticated independently of the leased user. Production code should check and report release failures rather than ignoring the response. The example keeps the central flow visible; add safe diagnostics without attaching credentials.

Treat storage state as scoped credential material

Store state under the project output directory or another ignored run directory, never beside source snapshots. Restrict CI artifact upload so generic glob patterns do not capture .auth. Clean the directory at run completion and rely on short session lifetimes as another control, not as the only cleanup.

Name files by project and stable slot when multiple projects share an output parent. A path based only on parallelIndex can collide across projects or shards if output directories are customized. Do not use the tenant name as proof of file ownership; validate the active tenant after login and again after context bootstrap.

If the product stores critical identity in sessionStorage, standard storage state does not capture it. Handle that explicitly through an approved initialization mechanism and keep the same secret protections. Avoid arbitrary local-storage injection that bypasses the application's tenant selection checks.

Prove positive identity before business actions

Every tenant-aware scenario should cheaply assert the active tenant through a stable product signal before mutation. This catches a valid session attached to the wrong workspace. The signal might be a tenant claim endpoint, workspace switcher, or immutable page marker.

Do not trust the requested hostname alone. Reverse proxies and application defaults can route a valid cookie to a different tenant. Make the expected tenant part of fixture output and compare it with what the application reports.

For role-sensitive suites, assert one representative permission at setup or in a dedicated contract test. Repeating a full permission audit before every scenario wastes time and can obscure the business test, but never let a broker role label stand as the only evidence.

Add explicit cross-tenant guard scenarios

Positive tests cannot prove isolation. Create a resource in tenant B through an independently authorized fixture, then attempt to access its unpredictable identifier with tenant A's context. Assert the exact denial policy chosen by the product and ensure the body, redirects, and UI contain no foreign metadata.

TypeScript
import { expect, test } from '../test-support/tenant-test';

test('an editor cannot read an invoice from another tenant', async ({
  context,
  tenantSession,
}) => {
  const foreignInvoiceId = process.env.QA_FOREIGN_INVOICE_ID;
  if (!foreignInvoiceId) throw new Error('Foreign tenant fixture is required');

  const response = await context.request.get(
    `/api/tenants/foreign/invoices/${foreignInvoiceId}`,
  );

  expect(response.status()).toBe(404);
  expect(await response.text()).not.toContain(foreignInvoiceId);

  const page = await context.newPage();
  await page.goto(`/invoices/${foreignInvoiceId}`);
  await expect(page.getByRole('heading', { name: 'Invoice not found' })).toBeVisible();
  await expect(page.getByText(tenantSession.tenantKey)).toBeVisible();
});

Use the application's documented non-disclosure status rather than copying 404 automatically. The foreign fixture must be authorized and isolated; a random nonexistent ID tests missing-resource behavior, not cross-tenant enforcement.

Bound retries, expiry, and account mutation

Authentication expiry should fail with a clear fixture error or trigger one controlled worker-level refresh before business actions begin. Silent reauthentication in the middle of any failed scenario can erase evidence of session revocation and create side effects under a new identity. Keep session-expiry behavior in dedicated tests.

Tests sharing a worker account still share its server-side preferences, notification state, and limits. Namespace mutable records per test and reset account-level settings. If scenarios change roles, passwords, MFA, or membership, lease a disposable account at test scope rather than corrupting the worker identity.

Retries may start in a replacement worker with the same parallel slot. The idempotent owner lets it recover the lease, but the application state from the failed attempt may remain. Cleanup and data namespaces must account for retry number without changing the user scenario being evaluated.

Recover from crashes without weakening isolation

Fixture teardown covers normal completion, not killed processes or cancelled machines. Give leases an expiry, renew only while the owning run is alive, and reconcile all leases by run ID in a final CI job. Release should verify owner identity so one shard cannot free another shard's active account.

Monitor broker capacity and stale leases as test infrastructure health. Do not solve exhaustion by letting workers wait without a bound or by sharing accounts. Fail setup with tenant, role, and owner metadata that is safe to log, then keep test execution from starting in a partially isolated state.

Use this security architecture checklist:

  • Tenant, role, account, run, shard, project, and parallel slot are distinct identities.
  • The broker assigns accounts atomically and never escalates role on exhaustion.
  • Storage state is protected, ignored by source control, and excluded from artifacts.
  • Each test gets a fresh browser context from the worker's leased identity.
  • Positive setup verifies the active tenant before any mutation.
  • Negative tests use a real foreign resource and assert the non-disclosure contract.
  • Lease expiry and run reconciliation cover process interruption.

Exercise the design under parallel load, forced worker failure, expired state, and a cross-tenant request before scaling the suite. Authentication architecture is ready only when the expected tenant is provable, the wrong tenant is unreachable, and every account remains attributable throughout the CI run.

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 11, 2026 / Reviewed July 11, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Playwright documentation

    Microsoft

    Canonical API, locator, fixture, browser, and test-runner behavior.

  2. 02
    Playwright best practices

    Microsoft

    Official guidance for resilient tests, isolation, and user-facing locators.

  3. 03
    Web Security Testing Guide

    OWASP Foundation

    Primary testing scenarios for identity, authorization, input validation, and web security controls.

  4. 04
    OWASP Top 10

    OWASP Foundation

    Current high-level web application security risk taxonomy.

FAQ / QUICK ANSWERS

Questions testers ask

Can parallel Playwright tests share one authenticated tenant account?

Only when the tests do not mutate account or tenant state. Suites that change server-side data should lease a distinct tenant-role account per parallel worker or use stronger per-test isolation.

What must identify a multi-tenant authentication lease in CI?

Use a run ID, shard, project, tenant, role, and stable parallel slot. A worker index alone is not globally unique and changes when Playwright replaces a failed worker.

Is a Playwright storage state file a secret?

Treat it as a credential because cookies and headers may permit impersonation. Store it under an ignored, access-controlled output path, limit artifact exposure, and delete it with the run.

How should tests verify cross-tenant isolation?

Use an identity from one tenant to request a known resource in another and assert the product's non-disclosing denial contract, then verify no foreign data appears in the UI or API body.

What happens to an account lease when a CI worker crashes?

Normal fixture teardown cannot be guaranteed, so the broker needs expiring leases, idempotent release, and a run-level reconciliation job that can reclaim accounts after interruption.