Skip to content
Vitaliy Potapov
Go back
Authentication in Playwright: You Might Not Need Project Dependencies

Authentication in Playwright: You Might Not Need Project Dependencies

If your Playwright suite mixes public pages and authenticated areas, or you test multiple roles like user and admin, the standard “setup project” approach for auth can make your runs slower than they need to be.

Here’s a simpler pattern that authenticates on demand, plays nicely with sharding, and keeps your config to one project per browser.

The usual way: a “setup” project

The Playwright docs on authentication recommend authenticating in a dedicated setup project and declaring it as a dependency for your browser projects. The dependency project runs first and prepares storageState that other projects reuse.

That’s convenient and visible in reports, but there’s a cost.

The big drawback: setup runs even when not needed

From the docs:

The setup project will always run and authenticate before all the tests

In practice this means:

You can split the config into separate projects per role, each with its own setup. That works, but once you add different browsers, the matrix grows and your config becomes hard to reason about. I prefer one project per browser and nothing else.

A simpler idea

What I really wanted:

  1. The first test that actually needs auth does the sign-in while others wait.
  2. Once done, the same storage state is shared across workers/tests that need it.
  3. Optionally, persist the state for a while on disk so local runs become instant.

That’s exactly what I use today with @global-cache/playwright, which I built after running into these downsides. Here’s how.

Code: multi-role auth on demand (no setup project)

I’ll use the example from the docs with admin and user roles. But instead of putting both logins into a setup project, I create a helper function and wrap authentication steps in globalCache.get():

// tests/helpers/auth.ts
import type { Browser } from '@playwright/test';
import { expect } from '@playwright/test';
import { globalCache } from '@global-cache/playwright';
/**
* Performs sign-in for a given role and caches the auth state for the whole run.
*/
export async function signIn(browser: Browser, role: 'admin' | 'user') {
return globalCache.get(`auth-state-${role}`, async () => {
console.log(`Signing-in as: ${role}`);
const page = await browser.newPage();
// Perform authentication steps. Replace these actions with your own.
await page.goto('https://github.com/login');
await page.getByLabel('Username or email address').fill(role);
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait until the page reaches a state where all cookies are set.
await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();
// Return authenticated state (cookies + localStorage)
return page.context().storageState();
});
}

Now each spec calls this helper inside a storageState fixture to authenticate only the role it needs, and only if a test actually runs:

tests/admin.spec.ts

import { test } from '@playwright/test';
import { signIn } from './helpers/auth';
// Make all tests in this file run as "admin"
test.use({
storageState: async ({ browser }, use) => {
const state = await signIn(browser, 'admin');
await use(state);
},
});
test('admin: sees dashboard', async ({ page }) => {
await page.goto('/admin');
// ...
});

tests/user.spec.ts

import { test } from '@playwright/test';
import { signIn } from './helpers/auth';
// Make all tests in this file run as "user"
test.use({
storageState: async ({ browser }, use) => {
const state = await signIn(browser, 'user');
await use(state);
},
});
test('user: can view profile', async ({ page }) => {
await page.goto('/me');
// ...
});

Running the tests

Below are 4 common runs to check the authentication setup:

1) Run everything (user + admin)

$ npx playwright test
Running 2 tests using 2 workers
✓ 1 test/admin.spec.ts:11:5 › admin: sees dashboard (2.7s)
✓ 2 test/user.spec.ts:11:5 › user: can view profile (2.8s)
Signing in as: admin
Signing in as: user
2 passed (3.5s)

What happens: user and admin authenticate once each, then all tests reuse their storage states.

2) Run only “user” tests

$ npx playwright test tests/user.spec.ts
Running 1 test using 1 worker
✓ 1 test/user.spec.ts:11:5 › user: can view profile (2.8s)
Signing in as: user

What happens: only the user role authenticates; admin never runs.

3) Run only “admin” tests

$ npx playwright test tests/admin.spec.ts
Running 1 test using 1 worker
✓ 1 test/admin.spec.ts:11:5 › admin: sees dashboard (2.7s)
Signing in as: admin

What happens: only the admin role authenticates; user never runs.

4) Run on two shards (split by files)

Shard 1:

$ npx playwright test --shard=1/2
Running 1 test using 1 worker, shard 1 of 2
✓ 1 test/admin.spec.ts:11:5 › admin: sees dashboard (2.7s)
Signing in as: admin
1 passed (3.7s)

Shard 2:

$ npx playwright test --shard=2/2
Running 1 test using 1 worker, shard 2 of 2
✓ 1 test/user.spec.ts:11:5 › user: can view profile (2.7s)
Signing in as: user
1 passed (3.6s)

What happens: the first shard authenticates admin; the second shard authenticates user. Each shard pays only for its role, which is why this setup executes faster than a multi-role dependency project.

In all examples, authentication runs only for roles that your tests actually touch. In practice this cuts setup time and makes your tests run faster.


Bonus: persistent auth for local dev

For local development, you can keep login state on disk for a limited time. For example, to cache for 1 hour, add a { ttl: '1 hour' } parameter to the authentication call:

await globalCache.get(`auth-state-${role}`, { ttl: '1 hour' }, async () => {
// ...perform login and return storageState()
});

The cache files live under .global-cache by default. You can inspect them and delete them to force a cache update.

How to enable Global Cache

To enable Global Cache, wrap your Playwright config with globalCache.wrap():

// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { globalCache } from '@global-cache/playwright';
const config = defineConfig({
projects: [
{ name: 'chromium' }, // keep it simple: one project per browser
],
// ...any other options
});
export default globalCache.wrap(config);

Notes

Side-by-side: dependency project vs Global Cache

AspectDependency projectGlobal Cache
When auth runsAlways before testsOnly when a test needs it
Multiple rolesAll roles log in up frontOnly selected roles log in
ShardingEach shard pays full setupEach shard computes only its role
ConfigProjects × roles × browsersOne project per browser
Persist across runsRoll your ownTTL for local runs

Wrap-up

Project dependencies are solid and officially recommended. But when you mix public pages, multiple roles, or heavy sharding, paying the authentication cost every time adds up. By authenticating on demand and sharing that state, you keep config lean, you only log in when a test truly needs it, and your local loop becomes fast with a short TTL.

If that sounds familiar, give this pattern a try and share your feedback ❤️


Share this post:

Previous Post
Turbopack: A Better Way to Inline SVG in Next.js 16
Next Post
Global Cache: Make Playwright BeforeAll Run Once for All Workers