What Your E2E Tests Don't Tell You About Session Security

1. The Problem
While building my one of my projects , my E2E suite was completely green. It handled the authentication flow, guarded protected routes, and verified redirect behaviors perfectly. But looking at the coverage, I realized a massive blind spot. What my E2E tests could never verify was the actual security lifecycle of a session:
Does a freshly created session resolve correctly to the right user?
Does the session expire after exactly 7 days?
Does rotation correctly extend expiry within the
updateAgewindow?Does deleting a user instantly invalidate all their active sessions?
None of these were verified. This was not a tooling limitation; it was a fundamental coverage gap. My suite reported success, but the core backend security guarantees surrounding session states remained entirely unverified.
2. Why E2E Tests Miss Session Behavior
While Playwright and Cypress both support clock manipulation. The limitation is the execution boundary: E2E clock manipulation controls the browser's JavaScript clock, not the server's system clock.
Session TTL expiry is evaluated server-side. When your auth middleware calls Date.now() to compare against a stored expiresAt timestamp, that executes on the server. To fake time server-side in an E2E test, you would need to either expose a test-only API endpoint that accepts a mocked timestamp, or inject an environment variable before each test run and restart the server. Both approaches mean you are no longer testing the real production system; you are testing a modified version of it.
Unit tests calling the auth API directly run in the same Node.js process as the auth logic. Calling vi.useFakeTimers patches Date globally in that specific process. The server-side expiry check reads the exact same faked clock. That is the clean solution.
3. The Setup — Calling Auth API Directly
The key insight is bypassing the HTTP layer entirely. Instead of making fetch requests to your server API routes, you call your authentication API methods directly in Vitest. This gives you programmatic control over everything.
Note: The examples below use a generalized auth.api object to represent the backend auth service. The underlying concepts apply identically regardless of whether you are using a managed library or a custom implementation.
This setup requires precise time manipulation. Vitest provides vi.useFakeTimers(), but calling it blindly breaks backend integration tests. The MongoDB Node.js driver relies on setInterval and setTimeout for connection pooling and heartbeats. Freezing all timers stalls the database connection, causing tests to silently hang. The critical fix is scoping the fake timers exclusively to the Date object using toFake: ['Date']. This pins the clock for your server-side auth logic while allowing the database driver's event loop to operate normally.
import { describe, beforeEach, afterEach, vi } from 'vitest';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
describe('Session Security Behavior', () => {
beforeEach(async () => {
// Crucial: Only fake Date to keep MongoDB driver alive
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-06-01T12:00:00Z'));
await db.collection('user').deleteMany({});
await db.collection('session').deleteMany({});
});
afterEach(() => {
vi.useRealTimers();
});
});
4. Critical Detail — The Session Cookie Format Problem
When testing auth flows programmatically, one detail often breaks the whole setup: the server and the test need to agree on the exact session format.
Different auth libraries handle sessions differently. Some use signed cookies, some use encrypted cookies, and some use opaque session tokens stored in cookies or headers. Because of that, the value returned from a signup or login API call is not always the same thing that the session validator expects later.
In many setups, the backend returns a session-related value, but session validation still expects the full cookie representation exactly as it would appear in an HTTP Cookie header. If you pass only part of that value, or skip the cookie layer entirely, session lookup may fail and return null.
Note: The exact cookie name, header shape, and extraction logic depend on the auth library you are using.
// 1. Force the API to return the raw HTTP response object
const response = await auth.api.signUpEmail({
body: { email: 'test@briefly.ai', password: 'Password123!', name: 'User' },
asResponse: true,
});
// 2. Extract the session cookie from Set-Cookie
const setCookieHeader = response.headers.get('Set-Cookie') || '';
const match = setCookieHeader.match(/auth\.session_token=([^;]+)/);
const sessionCookie = match ? match[1] : '';
// 3. Pass it back exactly as a browser would
const headers = new Headers({
cookie: `auth.session_token=${sessionCookie}`,
});
This is the kind of detail that can make or break an auth test suite.
5. The Four Tests
5a. Valid session lookup
it('validates an active session correctly', async () => {
const res = await auth.api.signUpEmail({ body: mockUser, asResponse: true });
const headers = extractSessionCookieHeaders(res); // Helper using logic from Section 4
const session = await auth.api.getSession({ headers });
expect(session).not.toBeNull();
expect(session?.user.email).toBe(mockUser.email);
});
This gives you a baseline. It checks that the session is created correctly, the cookie is extracted properly, and the auth layer can rebuild the session from the request headers.
5b. TTL expiry — session rejected after 7 days
it('rejects the session after the configured TTL expires', async () => {
const res = await auth.api.signUpEmail({ body: mockUser, asResponse: true });
const headers = extractSessionCookieHeaders(res);
// Advance clock beyond the session TTL
vi.setSystemTime(new Date('2026-06-09T12:00:00Z'));
const session = await auth.api.getSession({ headers });
expect(session).toBeNull();
});
This checks that the session expires according to the configured TTL. It verifies the real expiry behavior instead of relying on a shortened test-only rule.
5c. Session rotation extends expiry
it('rotates and extends the session if validated after the update window', async () => {
const res = await auth.api.signUpEmail({ body: mockUser, asResponse: true });
const headers = extractSessionCookieHeaders(res);
const initial = await auth.api.getSession({ headers });
const initialExpiry = initial!.session.expiresAt.getTime();
// Advance clock past the update window
vi.setSystemTime(new Date('2026-06-03T12:00:00Z'));
const rotated = await auth.api.getSession({ headers });
expect(rotated!.session.expiresAt.getTime()).toBeGreaterThan(initialExpiry);
});
This verifies sliding-session behavior. It checks that active users keep their session alive when the system is supposed to rotate or extend expiry.
5d. Account deletion — all sessions immediately invalidated
it('invalidates all active sessions when the user account is deleted', async () => {
// Arrange: Create two sessions for the same user
const res1 = await auth.api.signUpEmail({
body: mockUser,
asResponse: true,
});
const headers1 = extractSessionCookieHeaders(res1);
const res2 = await auth.api.signInEmail({
body: { email: mockUser.email, password: mockUser.password },
asResponse: true,
});
const headers2 = extractSessionCookieHeaders(res2);
// Verify both sessions are valid before deletion
expect(await auth.api.getSession({ headers: headers1 })).not.toBeNull();
expect(await auth.api.getSession({ headers: headers2 })).not.toBeNull();
// Act: Delete the user account
await auth.api.deleteUser({ headers: headers1 });
// Assert: Both sessions are now invalid
expect(await auth.api.getSession({ headers: headers1 })).toBeNull();
expect(await auth.api.getSession({ headers: headers2 })).toBeNull();
});
This test checks that deleting an account also kills every active session tied to that user. It helps catch cases where one device stays logged in even after the account is removed.
6. What E2E Still Owns
This is not an argument against E2E tests. E2E owns the browser flow: login redirect, protected route enforcement, logout redirect, and OAuth popup behavior. Unit tests own the security contract: expiry, rotation, invalidation, and tamper resistance. They test different things and both are necessary. Neither replaces the other.
7. What to Add Next
You can naturally extend this pattern to cover two additional tests. First, concurrent sessions — whether multiple active sessions for the same user work independently depends on your session configuration. If you limit concurrent sessions or enable session caching, a dedicated test suite verifying isolation between sessions catches misconfiguration before production does. Second, custom session fields — if your auth config extends the session object with additional user data, verifying that rotation and renewal cycles correctly preserve those fields is worth dedicated tests. A sliding window renewal that silently drops a custom role or permission field from the session object is the kind of bug that only surfaces in production when a user suddenly loses access mid-session. Same setup, same fake time approach.
8. Closing
The gap in your test suite is not the tests you wrote badly. It is the tests you never thought to write because E2E felt complete. Session security lives in time-dependent behavior that no browser test can reach. Fake time gives you that reach.



