The authentication problem at scale
Every test suite hits the same wall: you need to verify features behind a login page, but logging in before every single test is slow, unreliable, and a waste of resources. We've learned across multiple production projects that Playwright authentication setup: lessons from production is not about hacking together login logic—it's about building a reusable, maintainable foundation that your entire test suite can depend on. Once you solve authentication properly, your tests run faster, fail less often on network flakes, and let you focus on actual feature validation.
Setup project: log in once, save the session
Playwright's project system lets you define a setup task that runs before your test projects. This setup task logs in once and saves the authenticated session state as a JSON file. Every subsequent test project loads that state, skipping the login dance entirely.
Create a setup test file that handles authentication:
import { test as setup } from '@playwright/test';
setup('authenticate user', async ({ page }) => {
// Navigate to login page
await page.goto('https://app.example.com/login');
// Fill in credentials from environment variables
await page.fill('input[name="email"]', process.env.TEST_USER_EMAIL || '');
await page.fill('input[name="password"]', process.env.TEST_USER_PASSWORD || '');
// Click login and wait for navigation
await page.click('button:has-text("Sign In")');
await page.waitForURL('**/dashboard');
// Save the session state
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});This setup test does three things: navigates to your login page, enters credentials, and calls storageState() to capture cookies, local storage, and session storage in a JSON file. That file becomes your golden ticket—every test project can load it without re-authenticating.
Reuse it: configure projects with storage state
Once your setup file saves the authentication state, update your playwright.config.ts to reference it:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
// Load the saved authentication state
use: {
storageState: 'playwright/.auth/user.json',
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
dependencies: ['setup'],
use: {
storageState: 'playwright/.auth/user.json',
},
},
],
});The dependencies field tells Playwright to run the setup project first. The storageState option in the use config loads that saved session into every test's browser context. Now your actual tests start already logged in.
Your test files stay clean and focused:
import { test, expect } from '@playwright/test';
test('user can create an invoice', async ({ page }) => {
// No login needed—we're already authenticated
await page.goto('https://app.example.com/invoices');
await page.click('text=New Invoice');
await page.fill('input[name="amount"]', '500');
await page.click('button:has-text("Save")');
await expect(page.locator('text=Invoice created')).toBeVisible();
});Multiple roles: keep admin and customer separate
Most applications have different user roles. In production, we often test workflows for admins, customers, and support staff. Running all tests as a single user misses real bugs that only surface at the intersection of permissions.
Create separate setup files for each role:
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate as customer', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.fill('input[name="email"]', process.env.TEST_CUSTOMER_EMAIL || '');
await page.fill('input[name="password"]', process.env.TEST_CUSTOMER_PASSWORD || '');
await page.click('button:has-text("Sign In")');
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'playwright/.auth/customer.json' });
});
setup('authenticate as admin', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.fill('input[name="email"]', process.env.TEST_ADMIN_EMAIL || '');
await page.fill('input[name="password"]', process.env.TEST_ADMIN_PASSWORD || '');
await page.click('button:has-text("Sign In")');
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});Then configure separate projects for each role in your config:
export default defineConfig({
projects: [
{
name: 'auth-setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'customer-tests',
use: { ...devices['Desktop Chrome'] },
dependencies: ['auth-setup'],
use: {
storageState: 'playwright/.auth/customer.json',
},
testDir: './tests/customer',
},
{
name: 'admin-tests',
use: { ...devices['Desktop Chrome'] },
dependencies: ['auth-setup'],
use: {
storageState: 'playwright/.auth/admin.json',
},
testDir: './tests/admin',
},
],
});Now you can run npx playwright test --project=customer-tests or npx playwright test --project=admin-tests and each project loads the correct authentication state. Tests don't interfere, and you catch permission bugs you'd miss otherwise.
Keep secrets out: use environment variables
Never hardcode credentials in your test files or commit them to git. Use environment variables instead. Create a .env.local file (excluded from version control) or use your CI/CD system's secrets management:
// .env.local
TEST_USER_EMAIL=customer@example.com
TEST_USER_PASSWORD=SecurePassword123!
TEST_ADMIN_EMAIL=admin@example.com
TEST_ADMIN_PASSWORD=AdminPassword456!Load these with dotenv:
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
setup('authenticate', async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
throw new Error('TEST_USER_EMAIL and TEST_USER_PASSWORD must be set');
}
await page.goto('https://app.example.com/login');
await page.fill('input[name="email"]', email);
await page.fill('input[name="password"]', password);
await page.click('button:has-text("Sign In")');
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});In CI/CD, GitHub Actions, GitLab CI, or AWS CodePipeline all provide secure ways to inject secrets as environment variables. Your setup tests read them at runtime, authenticate, and save state—no credentials in code.
Common mistakes
- Forgetting to add setup to .gitignore: The
playwright/.auth/folder contains session tokens and cookies. Add it to.gitignoreimmediately. These files are not portable across machines and pose a security risk if committed. - Skipping the dependency chain: If you don't add
dependencies: ['setup']to your test projects, Playwright won't run setup before your tests. They'll fail with "not authenticated" errors. - Hard-coding role logic in tests: Avoid conditionals like
if (role === 'admin')inside test files. Separate test files or use distinct projects instead. It keeps tests focused and makes the test matrix explicit. - Assuming storageState is bulletproof: Session tokens expire. If your tests run days after setup, the stored state might be stale. For long-lived test suites, re-run setup daily or check for expired sessions in your setup test.
- Not validating the login succeeded: Always wait for a post-login URL or DOM element before saving state. Otherwise, setup might succeed but tests start in a logged-out state.
Key takeaways
- Create a dedicated setup test file that logs in and saves session state with
storageState(). - Configure test projects with
dependencies: ['setup']andstorageStatepointing to your saved auth JSON file. - Use separate auth files for each user role; organize test directories by role and load the correct state in each project.
- Store credentials in environment variables, never in code. Use
dotenvlocally and CI/CD secrets in production. - Add
playwright/.auth/to.gitignore; never commit session files. - Validate that login succeeded before saving state; wait for navigation or a DOM element to confirm.






Comments
Loading comments…