Why Your Playwright Tests Fail on Protected Routes (And the Simple Fix)
DEV Community

Why Your Playwright Tests Fail on Protected Routes (And the Simple Fix)

When I first started testing protected routes with Playwright, everything looked correct. From login to UI and JWT authentication, everything worked perfectly.

But the moment I tried to access a protected page in my E2E tests with Playwright, I kept running into a frustrating issue: 401 Unauthorized errors. At first, it didn’t make sense. The same flow worked perfectly in the browser. So Why was Playwright behaving differently?

I even tried running the login test first before the protected route test, but it still did not work! The issue wasn’t in my backend logic. It was in how browser authentication state works during automated testing. Playwright does not automatically share authentication state between tests or sessions.

In this article, I’ll show you the minimal and correct way to fix this using storageState.

The Problem

On a real device, you log in once, and the token is stored (in cookies or localStorage). With this, you stay authenticated as long as the token is still valid.

But in Playwright, things work differently because each test runs in a fresh browser context, and therefore, there are no cookies, localStorage, or tokens available for the next test. So even if you login in one test, the next test starts as a completely new user.

Hence, the 401 Unauthorized error, and then the test eventually timing out.

What Doesn’t Work

Running login in one test and expecting another test to stay authenticated:

//login test
test.describe('login page', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:5173/sign-in')
  })
  test('should redirect to home page on sign in', async ({ page }) => {
    await page.getByPlaceholder('Email').fill('e******@gmail.com')
    await page.getByPlaceholder('Password').fill('12345')
    await page.getByRole('button', { name: 'sign in' }).click()
    await expect(page).toHaveURL(/\/$/)
  })
})
//listing page(protected route) test
test.describe('listing page', () => {
  test('should have empty fields on initial load', async ({ page }) => {
    await page.goto('http://localhost:5173/create-listing')
    await expect(page.getByPlaceholder('Name')).toBeVisible()
    await expect(page.getByPlaceholder('Address')).toHaveValue('')
  })
})

As you can see below, our login test passed, but then the listing page test, which required authentication to be accessed, failed! E2E test demonstration with Playwright

How to Fix It

The clean solution is to save the authentication state after login:

await page.context().storageState({ path: 'auth.json' });

This code above saves the current browser session (cookies + localStorage) into a file called auth.json in the root folder (since we did not specify any folder in the path).

So we add the above code immediately after the login button click, and after navigation has completed (to ensure the login was successful) in the login test:

test.describe('login page', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:5173/sign-in')
  })
  test('should redirect to home page on sign in', async ({ page }) => {
    await page.getByPlaceholder('Email').fill('e******@gmail.com')
    await page.getByPlaceholder('Password').fill('12345')
    await page.getByRole('button', { name: 'sign in' }).click()
    await expect(page).toHaveURL(/\/$/)
    // Here we save the login state
    await page.context().storageState({ path: 'auth.json' });
  })
})
  • page.context: This refers to the browser context, not just the tab. And this browser context contains the cookies, localStorage, and authentication state.
  • storageState: This is a Playwright method that extracts and saves all auth-related browser data
  • {path:'auth.json'}: This tells Playwright to save everything into a file called auth.json.

So it creates something like this inside the auth.json file:

{
  "cookies": [
    {
      "name": "token",
      "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9*********",
      "domain": "localhost",
      "path": "/",
      "expires": 1774197120.965915,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Strict"
    }
  ],
  "origins": []
}

Now we configure Playwright to load and use the storageState we set above by adding this line inside the describe block of the test that involves a protected route:

test.use({ storageState: 'auth.json' })
  • test.use(): is used to configure how your tests run on Playwright.
  • storageState: is one of the options passed to test.use(). This tells Playwright to load the saved login session.
  • auth.json: is the name of the file where we saved the login session

Full code

// @ts-check
import { test, expect } from '@playwright/test'
test.describe('login page', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:5173/sign-in')
  })
  test('should redirect to home page on sign in', async ({ page }) => {
    await page.getByPlaceholder('Email').fill('e******@gmail.com')
    await page.getByPlaceholder('Password').fill('12345')
    await page.getByRole('button', { name: 'sign in' }).click()
    await expect(page).toHaveURL(/\/$/)
    await page.context().storageState({ path: 'auth.json' })
  })
})
test.describe('listing page', () => {
  test.use({ storageState: 'auth.json' })
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:5173/create-listing')
  })
  test('should have empty fields on initial load', async ({ page }) => {
    await expect(page.getByPlaceholder('Name')).toBeVisible()
    await expect(page.getByPlaceholder('Address')).toHaveValue('')
  })
})

This works because storageState saves your cookies, localStorage, and session data, so that Playwright can load your app as if the user is already logged in.

Note: Ensure you add the login session file (auth.json in our case) to .gitignore file to avoid exposing your application session

Final Takeaway

So, if your Playwright tests keep failing on protected routes, it’s not your backend, nor is it your JWT logic, but because it is missing authentication state in the test environment. Fix it once with storageState, and your E2E tests become stable and predictable.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.