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
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Access token expiry | 15-minute JWT | Cannot wait in CI | Short test TTL + clock |
| Refresh token rotation | Old refresh invalid | Mid-suite 401 | Two-step refresh spec |
| Idle timeout | Mouse move resets timer | Flaky without clock | Playwright clock + no activity |
| Absolute timeout | 8-hour max regardless of activity | Untested | Clock forward 8h |
| Sliding expiration | Each request extends | Conflicts with absolute cap | Test both policies |
| Logout | Cookie cleared client only | Session valid server-side | Probe after logout |
| Concurrent sessions | Max 3 devices | Fourth login kicks first | Multi-context spec |
| Tab sync | Logout in tab A, tab B stale | UX confusion | Two pages one context |
| Remember me | 30-day cookie | Extends beyond idle | Separate policy test |
| SSR session cookie | HttpOnly refresh | Client thinks logged in | Probe on SSR route |
| Token in memory only | Refresh on page load | New tab needs login | Context isolation test |
| Revoke on password change | Old refresh valid | Account compromise | Change pw probe |
| Revoke on admin disable | Session survives | Security gap | Disable user probe |
| Partial refresh failure | 401 loop | Infinite redirect | Assert login prompt |
| Clock skew | JWT nbf/exp | Flaky on VM | Sync time; short leeway in test env |
| Mobile background | App paused hours | Untested on web | Document; clock simulate |
| WebSocket auth | WS stays open after HTTP 401 | Realtime leak | WS close on expiry spec |
| Session fixation | Pre-login session id reused | Rare but critical | Login 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
| Action | Assert |
|---|---|
| User clicks logout | Cookies cleared; /api/me 401 |
| Server revoke session | Existing cookie 401 on next request |
| Password changed | All refresh tokens invalid |
| Admin disables user | Session 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
| Policy | Behavior | Test |
|---|---|---|
| Sliding | Activity extends session | Heartbeat resets idle |
| Absolute | Hard stop at T+8h | Clock +8h despite activity |
| Combined | Min(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
- Playwright clock for idle/absolute—no 15-minute real waits
- Test env short access token TTL (60–120s)
- Probe
/api/meas primary assert—not UI avatar - Refresh rotation reuse test included
- Logout replay old cookie → 401
- Document concurrent session policy in matrix
- Password-change / admin-disable revocation specs linked to RBAC guide
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
waitForTimeout(900000) | CI timeout | Playwright clock |
| Assert avatar visible | Token expired, cached UI | Probe /api/me |
| Skip refresh rotation | Stolen refresh long-lived | Reuse negative test |
| Client-only logout | Server session alive | Cookie replay probe |
| One login per suite | No expiry coverage | Clock specs |
| Prod token TTL in CI | Cannot wait | Test env short TTL |
| Ignore multi-tab | Prod confusion | Two-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.
- Arrange: Login; set access TTL 60s in test env; fill transfer form.
- Act: Advance clock 90s; stub refresh endpoint 401; click Submit.
- 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).
Related scenarios
- MFA / 2FA — step-up vs session expiry
- RBAC permissions — revoke on role change
- Auth0 and Okta SSO — IdP SSO session vs app session
- Firebase Authentication — session cookies + ID token refresh
- OAuth social login — OAuth refresh tokens
- Magic links — short-lived link sessions
External references
- Playwright clock
- Auth0 refresh tokens
- OAuth 2.0 refresh token best practices (RFC 9700)
- OWASP Session Management Cheat Sheet
- NextAuth session strategies
- Firebase session cookies
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.