|

Session and JWT Token Testing in Playwright

Most teams retype the same login form at the start of every single test, then watch their suite crawl and flake whenever an auth token quietly expires mid-run. The truth is that authentication is just data: a session cookie or a JWT sitting in storage, and you can capture it, inspect it, and fast-forward its clock on demand. In this guide on Playwright session JWT token testing, you’ll learn how to persist a logged-in session with storageState, decode and assert JWT claims, simulate token expiry with page.clock, and intercept refresh calls with page.route using Playwright and TypeScript.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why session and token testing matters

Modern web apps rarely use plain server-rendered sessions anymore. They hand the browser a JSON Web Token (JWT) after login, store it in localStorage or an HttpOnly cookie, and attach it to every API call as a Bearer token. That token carries an expiry (exp), an issued-at time (iat), and claims like roles or tenant IDs. If your tests never look at the token, you miss an entire class of bugs: silent logouts, missing refresh logic, role escalation, and expired-token crashes.

  • Speed — logging in through the UI for every test wastes minutes per run.
  • Stability — a flaky login page should not fail an unrelated checkout test.
  • Coverage — expiry, refresh, and tampered tokens are real user paths that the UI alone hides.

Capturing a session once with storageState

Playwright’s storageState snapshots cookies and origin-scoped localStorage/sessionStorage to a JSON file. The standard pattern is a setup project that logs in once and saves the state, then every other test reuses it. Here is a global setup script that authenticates and writes the state to disk.

// auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('https://app.example.com/login');
  await page.getByLabel('Email').fill('qa.user@example.com');
  await page.getByLabel('Password').fill('Sup3rSecret!');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait for a post-login signal so the token is actually persisted.
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Persist cookies + localStorage (where the JWT usually lives).
  await page.context().storageState({ path: authFile });
});

Now wire the setup as a dependency and point your tests at the saved state in playwright.config.ts. The dependencies key guarantees the setup project runs first, and storageState injects the session into every test in that project.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

With this in place, every test starts already logged in. No retyped credentials, no per-test login latency.

Decoding and asserting JWT claims

A JWT is three base64url segments separated by dots: header.payload.signature. You don’t need a verification library to read the claims in a test — the payload is just base64url-encoded JSON. Pull the token out of storage and decode the middle segment so you can assert on exp, iat, and custom claims.

// jwt.ts — a tiny, dependency-free decoder for tests
export interface JwtPayload {
  sub: string;
  exp: number;   // expiry as a Unix timestamp (seconds)
  iat: number;   // issued-at (seconds)
  role?: string;
  [key: string]: unknown;
}

export function decodeJwt(token: string): JwtPayload {
  const payload = token.split('.')[1];
  // base64url -> base64, then decode.
  const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
  const json = Buffer.from(normalized, 'base64').toString('utf8');
  return JSON.parse(json) as JwtPayload;
}

Now read the token straight from the browser with page.evaluate and assert that the claims are what you expect. This catches role bugs and tokens that are minted already-expired.

import { test, expect } from '@playwright/test';
import { decodeJwt } from './jwt';

test('issued JWT carries the right claims', async ({ page }) => {
  await page.goto('https://app.example.com/dashboard');

  const token = await page.evaluate(() => localStorage.getItem('access_token'));
  expect(token, 'access_token should be present').toBeTruthy();

  const claims = decodeJwt(token!);
  expect(claims.role).toBe('admin');
  expect(claims.sub).toBe('qa.user@example.com');

  // exp is in seconds; Date.now() is in ms. Token must not be expired yet.
  const nowSeconds = Math.floor(Date.now() / 1000);
  expect(claims.exp).toBeGreaterThan(nowSeconds);
});

If your app stores the token in an HttpOnly cookie instead of localStorage, you can’t read it from JavaScript. Use context.cookies() to grab it server-side instead, then decode the same way.

test('JWT stored in an HttpOnly cookie', async ({ page, context }) => {
  await page.goto('https://app.example.com/dashboard');

  const cookies = await context.cookies();
  const auth = cookies.find((c) => c.name === 'auth_token');
  expect(auth, 'auth_token cookie should exist').toBeTruthy();

  const claims = decodeJwt(auth!.value);
  expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000));
});

Simulating token expiry with page.clock

The hardest auth path to test is expiry, because real tokens often live for 15 minutes or an hour and you can’t wait that long in CI. Playwright’s page.clock API lets you install a controllable clock and fast-forward time so the browser believes the token has expired — without any real waiting. Install the clock before navigation, then jump forward past exp.

import { test, expect } from '@playwright/test';

test('app reacts when the access token expires', async ({ page }) => {
  // Pin the clock to a known starting point.
  await page.clock.install({ time: new Date('2026-06-27T10:00:00Z') });

  await page.goto('https://app.example.com/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Jump forward 20 minutes — past a 15-minute access-token lifetime.
  await page.clock.fastForward('20:00');

  // Trigger an action that hits a protected API with the now-expired token.
  await page.getByRole('button', { name: 'Refresh data' }).click();

  // App should surface a re-auth prompt rather than silently failing.
  await expect(page.getByText('Your session has expired')).toBeVisible();
});

page.clock controls Date, setTimeout, and setInterval in the page, so any client-side expiry check that compares Date.now() against the token’s exp will now see the token as stale. Use fastForward to skip time instantly, or pauseAt and runFor when you need timers to actually fire along the way.

Testing token refresh with page.route

Most production apps don’t just log the user out on expiry — they silently call a /refresh endpoint with a refresh token to get a new access token. You can validate this flow without a real backend by intercepting requests with page.route. The trick is to fail the first protected call with a 401, then return a fresh token on the refresh call, and assert the app retries successfully.

import { test, expect } from '@playwright/test';

test('client refreshes an expired access token transparently', async ({ page }) => {
  let refreshCalled = false;

  // 1) First hit to the protected resource returns 401 (token expired).
  let firstCall = true;
  await page.route('**/api/orders', async (route) => {
    if (firstCall) {
      firstCall = false;
      await route.fulfill({ status: 401, body: 'token expired' });
    } else {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([{ id: 1, total: 4200 }]),
      });
    }
  });

  // 2) The refresh endpoint hands back a brand-new access token.
  await page.route('**/api/auth/refresh', async (route) => {
    refreshCalled = true;
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ access_token: 'new.jwt.value' }),
    });
  });

  await page.goto('https://app.example.com/orders');
  await page.getByRole('button', { name: 'Load orders' }).click();

  // The app should recover and render data after the silent refresh.
  await expect(page.getByText('Order #1')).toBeVisible();
  expect(refreshCalled, 'refresh endpoint must be hit on 401').toBe(true);
});

This single test proves the retry interceptor works, the refresh token is sent, and the new token unblocks the original request — three behaviors that are nearly impossible to verify through the UI alone.

Injecting tokens directly with addInitScript

Sometimes you want to skip login entirely and start a test with a hand-crafted token — for example, to test an admin-only screen or a near-expiry token. context.addInitScript runs before any page script, so you can seed localStorage with a token of your choosing.

import { test, expect } from '@playwright/test';

test('seed a custom JWT before the app boots', async ({ page }) => {
  const fakeToken = 'header.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.sig';

  await page.addInitScript((token) => {
    window.localStorage.setItem('access_token', token);
  }, fakeToken);

  await page.goto('https://app.example.com/admin');
  await expect(page.getByRole('heading', { name: 'Admin Console' })).toBeVisible();
});

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Quick reference: which technique for which scenario

GoalPlaywright APINotes
Reuse a logged-in sessioncontext.storageState() + config storageStateCaptures cookies and localStorage; pair with a setup project.
Read a JWT from the pagepage.evaluate()Works only for non-HttpOnly storage.
Read a JWT from a cookiecontext.cookies()Use for HttpOnly tokens JS can’t see.
Simulate expirypage.clock.fastForward()No real waiting; controls Date and timers.
Mock 401 / refreshpage.route()Fulfill or modify responses to drive refresh logic.
Pre-seed a tokencontext.addInitScript()Runs before app scripts load.

Best practices for Playwright session JWT token testing

  • Never commit real tokens. Add playwright/.auth to .gitignore and regenerate state per run.
  • Decode, never verify, in tests. You only need the claims; signature verification belongs to the server.
  • Refresh state when it goes stale. If a saved storageState token expires between runs, your setup project should mint a fresh one each CI run.
  • Isolate worker auth. For parallel multi-user tests, give each worker its own account and state file to avoid token collisions.
  • Combine clock and route. Fast-forward past exp, then assert the refresh call fires — that’s the real-world expiry path.

Conclusion

Treating auth as inspectable data — not an opaque login wall — is what makes Playwright session JWT token testing fast and thorough. Capture the session once with storageState, decode the JWT to assert its claims, fast-forward page.clock to force expiry, and intercept the refresh call with page.route. Together these four moves let you cover login, expiry, refresh, and role-based access in seconds instead of minutes, and they expose the silent token bugs that pure UI tests never reach.

FAQ

Can Playwright read a JWT stored in an HttpOnly cookie?

Yes. JavaScript can’t see HttpOnly cookies, so page.evaluate(() => document.cookie) won’t return them. Instead use context.cookies(), which reads cookies at the browser-context level regardless of the HttpOnly flag. Find the cookie by name, then decode its value with a small base64url decoder to assert on the token’s claims.

How do I simulate an expired token without waiting for it to expire?

Use the page.clock API. Call page.clock.install() before navigating to pin a known start time, then page.clock.fastForward('20:00') to jump 20 minutes ahead. Because the clock controls Date.now(), setTimeout, and setInterval in the page, any client-side check comparing the current time to the token’s exp will treat it as expired instantly.

Is it safe to reuse storageState across many tests?

Yes, and it’s the recommended pattern for speed. Run a setup project once to log in and write the state file, then reference it via storageState in your config so every test starts authenticated. The main caveat is token lifetime: if the saved access token expires between the setup run and the tests, regenerate the state each CI run, and give parallel workers separate accounts to avoid session collisions.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.