Skip to main content

How to Test Session Expiry, Refresh Tokens, and Idle Logout

Short answer

Session bugs log users out unexpectedly or keep them in too long—UI "still logged in" does not reflect token expiry. Use Playwright clock for idle timeouts, probe /api/me after access-token expiry, test refresh rotation and concurrent session policies, and never waitForTimeout 30 minutes in CI.

Part of Testing Guides by auth and identity.

Who this is for

Teams shipping JWT access tokens, refresh tokens, session cookies, idle logout, or absolute session lifetime (Auth0, Okta, Firebase session cookies, NextAuth, custom Redis sessions) who need Playwright E2E that validates lifecycle policies without multi-hour CI jobs.

Typical stacks: SPA + refresh token in HttpOnly cookie, Next.js session cookies, BFF with sliding expiration, banking apps with 5-minute idle timeout.

Why testing session lifecycle matters

Session bugs erode trust and create security windows:

  • Revenue loss — users kicked out mid-checkout at 14-minute idle mark; abandon cart; support cannot reproduce without session policy tests.
  • Security incidents — refresh token never rotates; stolen refresh works for 30 days; "remember me" extends session beyond policy; logout does not revoke server-side session.
  • Support load — "random logout" when access token expires but silent refresh fails on Safari; multiple tabs desync after idle logout in one tab.
  • Compliance exposure — PCI requires 15-minute idle on admin; audit finds 24-hour JWT; shared kiosk sessions persist after navigate away.

E2E must probe API authorization over time—not only that login redirect succeeded once.

Complexity map

ScenarioEdge caseWhy tests breakApproach
Access token expiry15-minute JWTCannot wait in CIShort test TTL + clock
Refresh token rotationOld refresh invalidMid-suite 401Two-step refresh spec
Idle timeoutMouse move resets timerFlaky without clockPlaywright clock + no activity
Absolute timeout8-hour max regardless of activityUntestedClock forward 8h
Sliding expirationEach request extendsConflicts with absolute capTest both policies
LogoutCookie cleared client onlySession valid server-sideProbe after logout
Concurrent sessionsMax 3 devicesFourth login kicks firstMulti-context spec
Tab syncLogout in tab A, tab B staleUX confusionTwo pages one context
Remember me30-day cookieExtends beyond idleSeparate policy test
SSR session cookieHttpOnly refreshClient thinks logged inProbe on SSR route
Token in memory onlyRefresh on page loadNew tab needs loginContext isolation test
Revoke on password changeOld refresh validAccount compromiseChange pw probe
Revoke on admin disableSession survivesSecurity gapDisable user probe
Partial refresh failure401 loopInfinite redirectAssert login prompt
Clock skewJWT nbf/expFlaky on VMSync time; short leeway in test env
Mobile backgroundApp paused hoursUntested on webDocument; clock simulate
WebSocket authWS stays open after HTTP 401Realtime leakWS close on expiry spec
Session fixationPre-login session id reusedRare but criticalLogin changes session id

Playwright clock for idle timeout

Never wait 15 real minutes in CI:

test('idle logout after 15 minutes inactivity', async ({ page, context }) => {
await page.clock.install({ time: new Date('2025-06-01T09:00:00Z') });
await login(page, runId);
expect((await page.request.get('/api/me')).status()).toBe(200);

// No user activity — fast-forward idle threshold
await page.clock.fastForward('00:15:01');

// Trigger activity check (app may use setInterval)
await page.evaluate(() => window.dispatchEvent(new Event('focus')));

await expect.poll(async () => (await page.request.get('/api/me')).status()).toBe(401);
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});

Ensure your app reads time from Date.now() or injectable clock in test env—some libraries use performance.now() which Playwright clock also mocks.

Reference: Playwright clock.

Activity that resets idle timer

Verify which events reset idle (mousemove, keydown, API poll):

test('API poll keeps session alive within idle window', async ({ page }) => {
await page.clock.install({ time: new Date('2025-06-01T09:00:00Z') });
await login(page, runId);

for (let i = 0; i < 14; i++) {
await page.clock.fastForward('00:01:00');
await page.request.get('/api/me'); // heartbeat
}
expect((await page.request.get('/api/me')).status()).toBe(200);

await page.clock.fastForward('00:15:00'); // no heartbeat
expect((await page.request.get('/api/me')).status()).toBe(401);
});

Access token expiry and refresh

Short TTL in test environment

Configure staging/test auth server with 1-minute access token for E2E:

# auth server test config
ACCESS_TOKEN_TTL=60
REFRESH_TOKEN_TTL=3600
test('silent refresh extends session', async ({ page }) => {
await login(page, runId);
await page.clock.install({ time: new Date() });
await page.clock.fastForward('00:01:30'); // past 60s access TTL

// App should refresh on next API call
const res = await page.request.get('/api/me');
expect(res.status()).toBe(200);

const cookies = await page.context().cookies();
expect(cookies.find(c => c.name === 'refresh_token')).toBeTruthy();
});

Refresh token rotation

When rotation enabled, reusing old refresh token must fail:

test('refresh token rotation rejects reuse', async ({ request }) => {
const { refreshToken } = await loginAndCaptureTokens(request, runId);

const first = await request.post('/api/auth/refresh', { data: { refreshToken } });
expect(first.status()).toBe(200);
const { refreshToken: newRefresh } = await first.json();

const reuse = await request.post('/api/auth/refresh', { data: { refreshToken } });
expect(reuse.status()).toBe(401);

const valid = await request.post('/api/auth/refresh', { data: { refreshToken: newRefresh } });
expect(valid.status()).toBe(200);
});

Logout and server-side revocation

ActionAssert
User clicks logoutCookies cleared; /api/me 401
Server revoke sessionExisting cookie 401 on next request
Password changedAll refresh tokens invalid
Admin disables userSession dead within SLA
test('logout revokes server session', async ({ page, request }) => {
await login(page, runId);
const cookiesBefore = await page.context().cookies();

await page.getByRole('button', { name: 'Log out' }).click();
await expect(page).toHaveURL('/login');

// Replay old cookies
await page.context().addCookies(cookiesBefore);
expect((await request.get('/api/me')).status()).toBe(401);
});

Concurrent session limits

Banking and enterprise apps limit active sessions:

test('fourth login invalidates oldest session', async ({ browser }) => {
const contexts = await Promise.all([1, 2, 3, 4].map(() => browser.newContext()));
const pages = await Promise.all(contexts.map(c => c.newPage()));

for (const p of pages) {
await login(p, runId);
expect((await p.request.get('/api/me')).status()).toBe(200);
}

// First session should be kicked if max=3
expect((await pages[0].request.get('/api/me')).status()).toBe(401);
expect((await pages[3].request.get('/api/me')).status()).toBe(200);

await Promise.all(contexts.map(c => c.close()));
});

Use unique credentials or same user per product policy—document expected behavior.

Absolute vs sliding expiration

PolicyBehaviorTest
SlidingActivity extends sessionHeartbeat resets idle
AbsoluteHard stop at T+8hClock +8h despite activity
CombinedMin(sliding, absolute)Activity until absolute cap
test('absolute 8-hour cap logs out despite activity', async ({ page }) => {
await page.clock.install({ time: new Date('2025-06-01T09:00:00Z') });
await login(page, runId);

for (let h = 0; h < 7; h++) {
await page.clock.fastForward('01:00:00');
await page.request.get('/api/me');
}
expect((await page.request.get('/api/me')).status()).toBe(200);

await page.clock.fastForward('01:00:01');
expect((await page.request.get('/api/me')).status()).toBe(401);
});

Multi-tab behavior

test('idle logout in one tab affects all tabs', async ({ context, page }) => {
await page.clock.install({ time: new Date('2025-06-01T09:00:00Z') });
await login(page, runId);
const page2 = await context.newPage();
await page2.goto('/dashboard');

await page.clock.fastForward('00:15:01');
await page.request.get('/api/me'); // triggers idle check

await page2.reload();
await expect(page2).toHaveURL(/\/login/);
});

SSR and HttpOnly cookies

SPAs often miss SSR session state:

test('SSR page reflects expired session', async ({ page }) => {
await login(page, runId);
await expireSessionViaTestApi(runId);
const response = await page.goto('/dashboard', { waitUntil: 'commit' });
expect(response?.status()).toBe(302); // or 401 SSR
await expect(page).toHaveURL(/\/login/);
});

CI checklist

  1. Playwright clock for idle/absolute—no 15-minute real waits
  2. Test env short access token TTL (60–120s)
  3. Probe /api/me as primary assert—not UI avatar
  4. Refresh rotation reuse test included
  5. Logout replay old cookie → 401
  6. Document concurrent session policy in matrix
  7. Password-change / admin-disable revocation specs linked to RBAC guide

Anti-patterns

Anti-patternWhy it failsBetter approach
waitForTimeout(900000)CI timeoutPlaywright clock
Assert avatar visibleToken expired, cached UIProbe /api/me
Skip refresh rotationStolen refresh long-livedReuse negative test
Client-only logoutServer session aliveCookie replay probe
One login per suiteNo expiry coverageClock specs
Prod token TTL in CICannot waitTest env short TTL
Ignore multi-tabProd confusionTwo-page spec

Example scenario

Situation: User completes wire transfer form but access token expires before submit—silent refresh fails.

Expected outcome: User prompted to re-auth; draft saved; no partial transfer.

Why UI-only automation breaks: Submit button spins forever—test logged in once at start and never advanced clock.

  1. Arrange: Login; set access TTL 60s in test env; fill transfer form.
  2. Act: Advance clock 90s; stub refresh endpoint 401; click Submit.
  3. Assert: Login redirect or step-up modal; probe no transfer row; draft recoverable after re-login.

TestChimp workflow: Instrument session_end with reason idle|expired|logout; compare prod session duration vs test.

Same Arrange/Act/Assert pattern as expired-coupon checkout.

Connect scenarios to your QA workflow

Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).

External references

Frequently asked questions

How do I test 15-minute idle logout without waiting 15 minutes in CI?

Use Playwright page.clock.install and clock.fastForward to jump past idle threshold. Trigger your app idle check with focus or API call, then probe /api/me for 401. Ensure app uses Date.now() for idle calculations.

How do I test JWT access token expiry?

Configure test/staging auth with 60-second access token TTL. Login, advance clock past TTL, call protected API. Assert silent refresh succeeds—or login prompt if refresh fails.

What is refresh token rotation and how do I test it?

After rotation, each refresh returns a new refresh token and invalidates the old one. Test by refreshing twice with the original token—second attempt should 401. Third call with new token should 200.

How do I verify logout actually ends the server session?

Capture cookies after login, perform logout, re-add old cookies to context, probe /api/me. Expect 401. Client-only cookie delete is not logout.

Should I test concurrent session limits?

If product policy limits devices (common in banking), yes—open multiple browser contexts, login same user, assert oldest session invalidated. Document max sessions in test plan.

Users report random logouts—what should tests cover?

Cover access expiry, refresh failure, idle timeout, and absolute cap as separate specs. Instrument session_end_reason in prod via TrueCoverage to see which slice dominates—evolve tests to match.

Can TestChimp help maintain session lifecycle tests?

Use /testchimp init for short-TTL test config and session probes; Playwright clock specs stay stable across UI changes. TrueCoverage on session_end_reason shows prod gaps; evolve adds specs when refresh_failed spikes—link with // @Scenario: for PCI evidence.

Apply these patterns in your repo

Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.

Start free on TestChimp · Book a demo