Lighthouse on Authenticated Pages with Playwright

Run Lighthouse audits on pages behind login. Learn how to preserve authentication state when Lighthouse opens a new browser context.
Harlan WiltonHarlan Wilton Published

Running Lighthouse on pages that require authentication is tricky. Lighthouse opens a fresh page context, which means your login state doesn't automatically carry over.

The Problem

When you navigate to a protected page with Playwright and then run Lighthouse:

// This won't work as expected
await page.goto('https://app.example.com/login')
await page.fill('#email', 'user@example.com')
await page.fill('#password', 'password')
await page.click('button[type="submit"]')
await page.waitForURL('**/dashboard')

// Lighthouse opens a NEW page — login state is lost
const result = await lighthouse('https://app.example.com/dashboard', { port: PORT })
// ❌ Redirects to login page

Lighthouse creates its own page to run audits, discarding Playwright's authenticated session.

Solution: Storage State

Playwright can save and restore session state (cookies, localStorage, sessionStorage). Save it after login, then configure Lighthouse to use the same browser context.

Step 1: Save Authentication State

import { writeFileSync } from 'node:fs'
import { chromium } from 'playwright'

const PORT = 9222

async function saveAuthState() {
  const browser = await chromium.launch({
    args: [`--remote-debugging-port=${PORT}`],
  })

  const context = await browser.newContext()
  const page = await context.newPage()

  // Perform login
  await page.goto('https://app.example.com/login')
  await page.fill('#email', 'user@example.com')
  await page.fill('#password', 'password')
  await page.click('button[type="submit"]')
  await page.waitForURL('**/dashboard')

  // Save storage state
  const storageState = await context.storageState()
  writeFileSync('auth.json', JSON.stringify(storageState))

  await browser.close()
}

Step 2: Run Lighthouse with Saved State

import { readFileSync } from 'node:fs'
import lighthouse from 'lighthouse'
import { chromium } from 'playwright'

const PORT = 9222

async function auditAuthenticatedPage(url) {
  const browser = await chromium.launch({
    args: [`--remote-debugging-port=${PORT}`],
  })

  // Load saved authentication state
  const storageState = JSON.parse(readFileSync('auth.json', 'utf-8'))
  const context = await browser.newContext({ storageState })
  const page = await context.newPage()

  // Navigate to authenticated page
  await page.goto(url, { waitUntil: 'networkidle' })

  // Run Lighthouse with storage reset disabled
  const result = await lighthouse(url, {
    port: PORT,
    disableStorageReset: true, // Critical: preserves cookies/storage
    logLevel: 'error',
  })

  await browser.close()
  return result.lhr
}

auditAuthenticatedPage('https://app.example.com/dashboard')
Critical: Always set disableStorageReset: true. Without this, Lighthouse clears cookies and storage before the audit, logging you out.

Complete Working Example

Full script that handles login and audit in one flow:

import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import lighthouse from 'lighthouse'
import { chromium } from 'playwright'

const PORT = 9222
const AUTH_FILE = 'auth.json'

async function login(context) {
  const page = await context.newPage()
  await page.goto('https://app.example.com/login')
  await page.fill('#email', process.env.TEST_USER_EMAIL)
  await page.fill('#password', process.env.TEST_USER_PASSWORD)
  await page.click('button[type="submit"]')
  await page.waitForURL('**/dashboard')
  await page.close()
}

async function auditWithAuth(url) {
  const browser = await chromium.launch({
    args: [`--remote-debugging-port=${PORT}`],
  })

  let context

  // Reuse saved auth if available
  if (existsSync(AUTH_FILE)) {
    const storageState = JSON.parse(readFileSync(AUTH_FILE, 'utf-8'))
    context = await browser.newContext({ storageState })
  }
  else {
    context = await browser.newContext()
    await login(context)
    // Save for future runs
    const state = await context.storageState()
    writeFileSync(AUTH_FILE, JSON.stringify(state))
  }

  const page = await context.newPage()
  await page.goto(url, { waitUntil: 'networkidle' })

  const result = await lighthouse(url, {
    port: PORT,
    disableStorageReset: true,
    output: 'html',
  })

  writeFileSync('lighthouse-report.html', result.report)
  await browser.close()

  return result.lhr
}

auditWithAuth('https://app.example.com/dashboard')

Streamlining with Playwright Project Dependencies

Modern Playwright (v1.31+) allows defining setup projects. This separates login logic from your tests.

1. Configure playwright.config.ts:

import { defineConfig } from '@playwright/test'

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'lighthouse',
      use: {
        // Automatically load auth state
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'], // Run setup first
    },
  ],
})

2. Create setup file auth.setup.ts:

import { test as setup } from '@playwright/test'

setup('authenticate', async ({ page }) => {
  await page.goto('https://app.example.com/login')
  // ... perform login ...
  await page.context().storageState({ path: 'playwright/.auth/user.json' })
})

3. Run audit (no login logic needed):

test('dashboard performance', async ({ page }) => {
  // Page is already authenticated!
  await page.goto('https://app.example.com/dashboard')
  
  // Connect Lighthouse to this authenticated session
  // (Ensure you use the port and disableStorageReset pattern)
})

For token-based auth where storageState doesn't work (e.g., custom auth headers), inject cookies directly:

import lighthouse from 'lighthouse'
import { chromium } from 'playwright'

const PORT = 9222

async function auditWithCookies(url, cookies) {
  const browser = await chromium.launch({
    args: [`--remote-debugging-port=${PORT}`],
  })

  const context = await browser.newContext()

  // Add cookies to context
  await context.addCookies(cookies)

  const page = await context.newPage()
  await page.goto(url, { waitUntil: 'networkidle' })

  const result = await lighthouse(url, {
    port: PORT,
    disableStorageReset: true,
  })

  await browser.close()
  return result.lhr
}

// Usage
auditWithCookies('https://app.example.com/dashboard', [
  {
    name: 'session_token',
    value: 'your-token-here',
    domain: 'app.example.com',
    path: '/',
  },
])

Handling Session Expiry

Long test runs can cause session tokens to expire. Add a check:

async function ensureAuthenticated(page, context) {
  await page.goto('https://app.example.com/dashboard')

  // Check if redirected to login
  if (page.url().includes('/login')) {
    await login(context)
    const state = await context.storageState()
    writeFileSync(AUTH_FILE, JSON.stringify(state))
    await page.goto('https://app.example.com/dashboard')
  }
}

Common Pitfalls

IssueSolution
Session lost after auditAdd disableStorageReset: true
CSRF token invalidRe-login before each audit
Cookies not applyingCheck domain matches exactly
Auth works in Playwright but not LighthouseLighthouse uses a new page — ensure cookies are on the context, not just the page