Auth: sessions, JWT, OAuth2, OIDC
Where test suites go to die. Understanding token lifetimes, refresh flows and scopes is the difference between a login helper that works for two years and one that breaks every sprint — and it is also half of the security layer.
Most test suites have a login helper that logs in once, stores a token, and reuses it for every test that follows. That helper is usually written in an afternoon by someone who does not know how the auth actually works, and it keeps working right up until a refresh flow changes or a token expiry gets shortened. Auth is where test suites go to die, and understanding the mechanics is what keeps a login helper alive for years instead of weeks.
Sessions versus tokens
A session-based login stores state on the server. The browser holds an opaque session ID in a cookie, and the server looks that ID up in a store to find out who is logged in. Log the user out, and the server deletes the session. The cookie itself is worthless without it.
A JWT (JSON Web Token) flips that around. The token carries the claims itself: user ID, roles, an expiry timestamp, all signed so the server can verify nobody tampered with it. Nothing is stored server side, which is why JWTs scale well across multiple services.
That also means a JWT cannot be revoked early the way a session can. Once issued, it stays valid until it expires. Some systems track a denylist to work around this, but many do not bother.
OAuth2: who is authorizing what
OAuth2 is not a login protocol by itself. It is a delegation protocol: a way for one system to get limited access to another system's resources on a user's behalf, without ever seeing the user's password. The authorization code flow matters most for web and mobile apps. The user is redirected to an identity provider, approves access, and the app receives a code it exchanges for tokens.
The access token is short lived, often 15 minutes to an hour. The refresh token is long lived, and it is the one that quietly breaks test suites. A refresh token can be revoked, rotated on each use, or scoped to a device. A login helper that only understands access tokens will start failing the moment refresh rotation ships. The cached token it planned to reuse is no longer valid the second time.
// Naive helper: caches tokens once and reuses them forever.
// Breaks the moment refresh token rotation is enabled server side.
let cachedTokens: { access: string; refresh: string } | null = null;
async function loginAsUser(user: string) {
if (cachedTokens) return cachedTokens;
const res = await fetch("/auth/login", {
method: "POST",
body: JSON.stringify({ user }),
});
cachedTokens = await res.json();
return cachedTokens;
}A safer version performs a fresh login per test file and treats a rejected refresh token as an expected outcome to assert on, not an error to suppress.
OIDC on top of OAuth2
OpenID Connect (OIDC) adds identity to OAuth2's authorization. Where OAuth2 answers "can this app act on the user's behalf," OIDC answers "who is this user, actually." It does that with an ID token, a JWT carrying identity claims, issued alongside the access token during the same flow.
This is why most consumer login buttons ("Sign in with Google") are OIDC, not bare OAuth2. The app needs to know who logged in, not just get permission to call an API. Testing an OIDC integration means checking that the ID token's claims (email, subject ID, issuer) match what the app displays, not just that login succeeded. This kind of claim validation is a natural extension of API testing and schema validation: the token is just another payload with a shape to verify.
A token itself should also never show up in a test log or a screenshot. Treat it the same way you would any other credential, per the guidance in secrets management: redact it before it is written anywhere durable.
A worked example: the login helper that broke every sprint
Consider a payments team at a mid-size fintech that ran a Playwright suite with a single loginAsUser() helper. It logged in once per test run, cached the access and refresh tokens in a file, and every test reused them. This ran fine for eight months.
Then the identity team enabled refresh token rotation, where each use of a refresh token invalidates the previous one and issues a new one. The cached refresh token in the suite was now single-use. The first test that ran consumed it, and every test after that failed with an error that looked, in the CI logs, like flaky networking rather than a token problem.
The fix was to stop caching tokens across runs and perform a fresh login per test file, accepting slower runs as the cost of correctness. The team also added an explicit test asserting that rotation was happening: log in, use the refresh token once, confirm the old one is rejected on a second attempt. That test would have caught the rotation change the day it shipped, instead of three weeks later when someone finally traced the flakiness back to auth.
What to actually test
- Token expiry: does the app refresh silently, or force a re-login, and does your suite exercise both paths.
- Refresh token rotation: does reusing an old refresh token get rejected, and does the app handle that rejection gracefully.
- Scope enforcement: a token with the wrong scope should get a clear 403, not a confusing 500.
- Logout: does logout actually invalidate the session or token server side, not just clear it from the browser.
- Clock skew: a JWT with
expa few seconds in the past due to drift between services should not intermittently fail valid requests.
Wiring these checks into a pipeline is worth doing once, not per project. If your suite already runs through CI/CD for test suites, add a scheduled job that logs in with a real refresh token weekly, just to catch rotation or expiry policy changes before a human notices.
FAQ
Questions people ask
Should I mock auth entirely in my test suite to avoid this complexity?
Mock it for unit and component tests where auth is not the thing under test, but keep at least one end-to-end suite exercising the real flow, since mocked auth cannot catch a real rotation or scope change.
How do I test an expired JWT without waiting for it to actually expire?
Forge one with a short expiry using the same signing key in a test environment, or ask the identity provider for a test-only short-lived token issuance endpoint if one exists.
Why does my test pass locally but fail in CI with an auth error?
CI often runs with stricter clock sync or a shared, rate-limited identity provider sandbox. Check for clock skew first, then check whether concurrent CI jobs are exhausting a shared token quota.
Is OAuth2 the same thing as SSO?
No. Single sign-on is a user experience goal: log in once, access many apps. OAuth2 and OIDC are two protocols commonly used to build it, but SSO can also be implemented with SAML.