Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 013 / 100

Playwright authentication setup: lessons from production

Log in once, reuse the session everywhere.

Expona Admin6 min read

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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
// .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:

typescript
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 .gitignore immediately. 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'] and storageState pointing 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 dotenv locally 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles