Authentication Testing Guide

How to Test JWT Refresh Token Rotation with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing JWT refresh token rotation with Playwright. Cookie vs Authorization header delivery, access token expiry races, refresh token rotation, concurrent tab refresh collisions, token revocation, and the subtle bugs that silently break production auth.

82%

According to the 2025 OWASP API Security Top 10, broken authentication (including improper token lifecycle management) remains the number one API security risk, with 82% of tested APIs exhibiting at least one authentication flaw.

OWASP API Security Project

0minTypical access token TTL
0Auth scenarios covered
0xTokens per rotation cycle
0%Fewer lines with Assrt

JWT Refresh Token Rotation Flow

BrowserApp ServerAuth ServerToken StoreAPI request with access token401 Unauthorized (token expired)POST /auth/refresh with refresh tokenValidate refresh tokenRevoke old refresh tokenStore new refresh tokenNew access + refresh token pairSet new tokens, retry original request

1. Why Testing JWT Refresh Token Rotation Is Harder Than It Looks

JWT refresh token rotation is one of those features that works perfectly in a demo and breaks silently in production. The basic concept is simple: when an access token expires, the client sends its refresh token to get a new pair of tokens, and the server invalidates the old refresh token so it can never be reused. But the real-world implementation is full of timing-dependent edge cases that only surface under specific conditions.

The first structural challenge is the dual delivery mechanism. Some applications store tokens in HttpOnly cookies set by the server, while others send them via the Authorization header from client-side JavaScript. These two approaches have completely different test strategies. Cookie-based flows require intercepting Set-Cookie headers and verifying cookie attributes (HttpOnly, Secure, SameSite). Header-based flows require inspecting request headers and JavaScript-accessible storage (localStorage, sessionStorage, or in-memory state).

The second challenge is timing. Access tokens typically live for 5 to 15 minutes. Testing expiry means either waiting (which makes tests unbearably slow) or manipulating time (which requires server cooperation or token mocking). The third challenge is concurrency: when a user has two tabs open and the access token expires in both simultaneously, both tabs race to refresh. With rotation enabled, the first tab succeeds but the second tab sends an already-rotated refresh token, which should trigger a security alert and potentially revoke the entire token family. The fourth challenge is revocation propagation: when a user logs out, the refresh token must be invalidated server-side, and any in-flight refresh attempts using that token must fail cleanly. The fifth challenge is replay detection: if an attacker steals a refresh token and uses it after the legitimate client has already rotated it, the server must detect this and revoke all tokens in the family.

Token Lifecycle: Why Each Stage Is Testable

🔒

Login

Initial token pair issued

🌐

API Calls

Access token in header/cookie

Expiry

Access token TTL exceeded

⚙️

Refresh

Exchange refresh for new pair

↪️

Rotation

Old refresh token invalidated

Revocation

Logout kills token family

Concurrent Tab Refresh Collision

🌐

Tab A

Detects expired access token

🌐

Tab B

Also detects expiry

Tab A Refreshes

Gets new token pair

Tab B Refreshes

Sends now-stale refresh token

🔒

Server Detects

Replay of rotated token

Family Revoked

All tokens invalidated

A robust JWT refresh test suite must cover all of these surfaces. The sections below walk through each scenario with runnable Playwright TypeScript code you can copy directly into your project.

2. Setting Up a Reliable Test Environment

Testing token refresh rotation requires precise control over token lifetimes. You cannot test expiry if your access tokens live for an hour; you need a test configuration with short TTLs (5 to 30 seconds) so your tests complete in reasonable time. Most auth servers (Auth0, Keycloak, custom implementations) let you configure token TTLs per environment. Create a dedicated test configuration that uses aggressively short lifetimes.

JWT Refresh Token Test Environment Checklist

  • Configure access token TTL to 10-30 seconds for test environment
  • Configure refresh token TTL to 60-120 seconds
  • Enable refresh token rotation in auth server settings
  • Enable replay detection (revoke token family on reuse)
  • Set up a test user with known credentials
  • Prepare API endpoints that return token metadata for assertions
  • Configure CORS to allow Playwright's browser context
  • Disable rate limiting on auth endpoints for test environment

Environment Variables

.env.test

Playwright Configuration for Token Testing

Token refresh tests are timing-sensitive. Configure Playwright with a generous action timeout to accommodate the wait for token expiry. Use a single worker to avoid test interference when testing token revocation and family invalidation, since those operations affect shared server state.

playwright.config.ts

Token Inspection Helper

You will need to decode JWTs in your tests to verify claims, expiry timestamps, and token IDs. This helper decodes the payload without verifying the signature (which is acceptable in test code where you control the auth server).

test/helpers/jwt-decode.ts
Verify Test Environment Setup

3. Scenario: Happy Path Token Refresh After Expiry

The foundational scenario is the simplest: a user logs in, their access token expires, the application silently refreshes it, and the user continues without interruption. This is your smoke test for the entire refresh pipeline. If this scenario fails, every authenticated user will be logged out after the access token TTL expires. The test must verify three things: the new access token is different from the old one, the new refresh token is different from the old one (rotation), and the application retries the failed request transparently.

1

Happy Path Token Refresh After Expiry

Straightforward

Goal

Log in, wait for the access token to expire, trigger an API request, and verify that the refresh happens transparently with rotation (new refresh token issued, old one invalidated).

Preconditions

  • Access token TTL set to 15 seconds in test environment
  • Refresh token rotation enabled on the auth server
  • Test user exists with valid credentials

Playwright Implementation

jwt-refresh-happy-path.spec.ts

What to Assert Beyond the UI

  • The new access token has a fresh exp claim (15 seconds from now, not from the original login)
  • The new refresh token has a different jti (token ID) than the original
  • The old refresh token returns 401 if you attempt to use it again
  • The application retried the original failed request without user interaction

Happy Path Token Refresh: Playwright vs Assrt

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

test('access token refresh with rotation', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: /sign in/i }).click();
  await page.waitForURL(/\/dashboard/);

  const initialCookies = await page.context().cookies();
  const initialAccessToken = initialCookies.find(
    c => c.name === 'access_token'
  )?.value;

  await page.waitForTimeout(16_000);
  await page.getByRole('button', { name: /load profile/i }).click();
  await page.waitForTimeout(2_000);

  const refreshedCookies = await page.context().cookies();
  const newAccessToken = refreshedCookies.find(
    c => c.name === 'access_token'
  )?.value;
  expect(newAccessToken).not.toBe(initialAccessToken);
  await expect(page.getByText(process.env.TEST_USER_EMAIL!))
    .toBeVisible();
});
55% fewer lines

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: Concurrent Tab Refresh Race Condition

This is the scenario that most teams discover in production, not in testing. A user has two tabs open to the same application. Both tabs hold the same access token and the same refresh token. When the access token expires, both tabs simultaneously detect the expiry and both attempt to refresh. With rotation enabled, the first request succeeds and rotates the refresh token. The second request arrives with the now-invalidated refresh token. The auth server must detect this as either a legitimate race condition (and handle it gracefully with a short reuse window) or a potential token theft (and revoke the entire token family).

The correct behavior depends on your security posture. Some implementations allow a brief reuse interval (typically 2 to 10 seconds) where the old refresh token is still accepted, returning the same new token pair. Stricter implementations treat any reuse as theft and immediately revoke all tokens in the family, forcing all tabs to re-authenticate. Your test must verify whichever behavior your server implements.

3

Concurrent Tab Refresh Race Condition

Complex

Goal

Simulate two browser contexts sharing the same refresh token, both attempting to refresh simultaneously after access token expiry. Verify the server handles the race condition correctly per its configured policy.

Playwright Implementation

jwt-concurrent-refresh.spec.ts

Concurrent Refresh: Playwright vs Assrt

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

test('concurrent tab refresh', async ({ browser }) => {
  const contextA = await browser.newContext();
  const contextB = await browser.newContext();
  const tabA = await contextA.newPage();
  const tabB = await contextB.newPage();

  // ... login on Tab A, copy cookies to Tab B ...
  await tabA.waitForTimeout(16_000);

  const [responseA, responseB] = await Promise.all([
    tabA.getByRole('button', { name: /load profile/i }).click()
      .then(() => tabA.waitForResponse(r =>
        r.url().includes('/api/'))),
    tabB.getByRole('button', { name: /load profile/i }).click()
      .then(() => tabB.waitForResponse(r =>
        r.url().includes('/api/'))),
  ]);

  // Assert per server policy
  expect(responseA.status()).toBe(200);
  await contextA.close();
  await contextB.close();
});
62% fewer lines

6. Scenario: Rotation Replay Detection (Stolen Token)

Token rotation exists specifically to detect theft. If an attacker intercepts a refresh token and attempts to use it after the legitimate client has already rotated to a new one, the auth server should recognize this as replay of an already-consumed token and revoke the entire token family. This means the legitimate user is also logged out, which is the correct security trade-off: it is better to force a re-login than to allow an attacker to maintain persistent access.

Testing this requires a precise sequence: authenticate, capture the refresh token, perform a legitimate refresh (which rotates the token), then attempt to use the captured (now-old) refresh token directly via the API. The server should reject it and invalidate all tokens in the family.

4

Rotation Replay Detection

Complex

Goal

Simulate an attacker replaying a stolen refresh token after the legitimate client has already rotated. Verify the server detects the replay and revokes all tokens in the token family.

Playwright Implementation

jwt-replay-detection.spec.ts

What to Assert Beyond the UI

  • The replayed refresh token returns 401 with a descriptive error code
  • All tokens in the same family are revoked (the legitimate user is also logged out)
  • The auth server logs a security event for the replay attempt
  • Subsequent API calls from the legitimate browser context fail until re-authentication

7. Scenario: Token Revocation on Logout

Logging out with JWT tokens is more nuanced than clearing cookies. The server must revoke the refresh token so it cannot be used to obtain new access tokens. Since access tokens are stateless, they remain valid until their exp claim passes, which is why short access token TTLs are critical. Your test must verify that after logout, the refresh token is truly invalidated on the server side, not just removed from the browser.

5

Token Revocation on Logout

Moderate

Playwright Implementation

jwt-revocation-logout.spec.ts

Logout Revocation: Playwright vs Assrt

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

test('logout revokes refresh token', async ({ page, request }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: /sign in/i }).click();
  await page.waitForURL(/\/dashboard/);

  const cookies = await page.context().cookies();
  const refreshToken = cookies.find(
    c => c.name === 'refresh_token')?.value!;

  await page.getByRole('button', { name: /log out/i }).click();
  await page.waitForURL(/\/login/);

  const response = await request.post(
    `${process.env.AUTH_SERVER_URL}/auth/refresh`,
    { data: { refresh_token: refreshToken } }
  );
  expect(response.status()).toBe(401);
});
57% fewer lines

8. Scenario: Access Token Expiry During In-Flight Request

This edge case is particularly tricky. Imagine a slow API endpoint that takes 5 seconds to respond. The client sends the request with a valid access token. While the request is in flight, the access token expires. The server may reject the request with 401 even though the token was valid when it was sent. Robust applications handle this by checking the response, refreshing the token, and retrying the request. Your test must verify this retry-after-refresh behavior works correctly.

6

Expiry During In-Flight Request

Complex

Playwright Implementation

jwt-inflight-expiry.spec.ts

What to Assert Beyond the UI

  • The initial request returned 401 (token expired during processing)
  • The application triggered a refresh automatically
  • The retried request used the new access token
  • The user saw no error; the retry was transparent
JWT Refresh Token Test Suite Run

9. Common Pitfalls That Break JWT Refresh Test Suites

Using Real Token TTLs in Tests

Production access tokens often live for 15 to 60 minutes. If you test against production TTLs, each test that involves token expiry takes 15 or more minutes to complete. Your CI pipeline becomes unusably slow, and developers skip the tests. Always configure a short TTL (10 to 30 seconds) in your test environment. Some teams add a /admin/fast-expire endpoint in their test auth server that immediately expires a specific token, eliminating the wait entirely.

Ignoring the Clock Skew Problem

JWT expiry checks compare exp against the current server time. If the auth server and the resource server have even slightly different clocks, tokens may be rejected before they should expire, or accepted after they should be dead. In CI environments with Docker containers, clock skew between containers is a real source of flaky tests. Pin your containers to the same NTP source, or add a 2 to 5 second tolerance buffer in your expiry assertions.

Testing Rotation Without Replay Detection

Rotation without replay detection is security theater. If you rotate the refresh token but never check whether a previously rotated token is being reused, an attacker who steals a refresh token can still use it indefinitely (each use triggers a rotation, but the server does not notice the old one was already consumed). Always test the replay detection path from Section 6, not just the happy path rotation.

Forgetting to Test the Refresh Token's Own Expiry

Refresh tokens also expire. If a user closes their laptop for a week and returns, their refresh token may have expired. The application must detect this (the refresh endpoint returns 401 or 403) and redirect to the login page cleanly, not crash or show a blank screen. This scenario is easy to overlook because in normal testing, the refresh token never expires.

Parallel Test Workers Sharing Token State

If two test workers use the same test user and one worker triggers a token rotation, the other worker's refresh token becomes invalid. This causes intermittent 401 errors that are extremely difficult to reproduce locally. Either use one worker for token lifecycle tests, or provision a unique test user per worker.

JWT Refresh Token Test Anti-Patterns

  • Using production token TTLs in test environment
  • Testing rotation without testing replay detection
  • Sharing test user credentials across parallel workers
  • Only clearing cookies on logout without revoking server-side
  • Ignoring clock skew between containers in CI
  • Not testing the refresh token's own expiry path
  • Assuming the refresh endpoint never fails or is slow
  • Testing only cookie delivery when the app uses both cookies and headers
Common JWT Refresh Failure in CI

10. Writing These Scenarios in Plain English with Assrt

Every scenario above relies on precise timing, cookie inspection, header interception, and multi-context orchestration. The Playwright code is powerful but brittle: rename a cookie from refresh_token to rt, change the refresh endpoint from /auth/refresh to /oauth/token, or switch from cookie delivery to header delivery, and half your tests break. Assrt lets you describe what you are testing in plain English, generates the Playwright code with the correct selectors and endpoints, and regenerates automatically when the implementation changes.

The concurrent tab refresh scenario from Section 5 is the best illustration. In raw Playwright, you manage two browser contexts, copy cookies manually, orchestrate parallel clicks, and branch on response status codes. In Assrt, you describe the intent: two tabs, same session, simultaneous refresh, verify the outcome.

scenarios/jwt-refresh-full-suite.assrt

Assrt compiles each scenario into the same Playwright TypeScript you saw in the preceding sections. The generated code handles cookie inspection, header interception, multi-context setup, and timing waits automatically. When your auth server changes its cookie names, endpoint paths, or rotation policy, Assrt detects the failures, analyzes the new behavior, and opens a pull request with updated test code. Your scenario files remain untouched.

Start with the happy path refresh scenario. Once it is green in CI, add the replay detection scenario (it is the most important security test), then concurrent tab refresh, then logout revocation, then the in-flight expiry edge case. In a single afternoon you can have comprehensive JWT refresh token rotation coverage that catches the subtle bugs most teams only discover through production incidents.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk