Authentication fails quietly when tokens are trusted without verification, stored carelessly, or refreshed under a race. This spec fixes the lifecycle: what each token is, how it is verified before it is trusted, where it is stored on the client and the server, how refresh rotates it with reuse detection, and how a session is revoked. Every clause closes a specific, common bypass.
Conformance language follows RFC 2119: MUST / MUST NOT are enforceable requirements, SHOULD / SHOULD NOT are strong recommendations with legitimate exceptions, and MAY marks a genuine choice.
Contract
Token types
| Token | Lifetime | Rules |
|---|---|---|
| Access | Short (minutes) | Signed, verified every request; iss/aud pinned |
| Refresh | Long (days), rotating | Server-revocable; rotated and invalidated on use |
Definition
Verification rule
A token is trusted only after its signature, expiry, issuer, and audience are verified. Decoding the payload proves nothing — the claims are attacker-controlled until the signature is checked. Verification failure is a rejected request; it is never a fall-through to trusting the decoded claims.
const { payload } = await jwtVerify(token, publicKey, {
issuer: EXPECTED_ISSUER, // pin who issued it
audience: EXPECTED_AUDIENCE, // pin that it was issued for us
});
// Only now may payload.sub / claims be trusted.Contract
Client-side storage rule
There is no single correct place to keep a token — the right store depends on the client platform and its threat model. The requirement is constant: a token at rest MUST be protected against that platform's realistic attackers and MUST NOT sit anywhere untrusted code can read it. The mechanism varies by platform.
| Platform | Access token | Refresh token |
|---|---|---|
| Native mobile | Hardware-backed Keychain (iOS) / Keystore-encrypted store (Android) | Same store, written atomically with the access token |
| Traditional web app | HTTP-only, Secure, SameSite cookie — never JS-readable | HTTP-only Secure cookie, or a server-side session record |
| SPA / browser | In memory only — not localStorage or sessionStorage | Not stored: use the Authorization Code flow with PKCE and short-lived tokens |
| Server-to-server | In memory or a secret manager; short-lived and re-fetched | In a secret manager — never in source, config, or logs |
Access tokens for web apps SHOULD be delivered in HTTP-only cookies so page scripts cannot read them. SPA refresh tokens SHOULD NOT be placed in localStorage — use the Authorization Code flow with PKCE instead. On native platforms, the access and refresh tokens are written as a single atomic record so a crash mid-write cannot leave a torn state that logs the user out.
Definition
Server-side refresh token storage
On the server a refresh token is a credential, and it MUST be stored only as a slow hash — bcrypt, Argon2, or comparable — never in plaintext. The plaintext value exists transiently, at issuance and during the constant-time comparison at verification, and is never persisted. This is defense-in-depth: if the token store is exfiltrated, the attacker holds hashes, not usable tokens.
// Issue: the plaintext leaves the server once, to the client. Persist the hash.
$record->token_hash = password_hash($plaintextRefreshToken, PASSWORD_BCRYPT);
// Verify: constant-time compare; the plaintext is never written to storage.
if (! password_verify($presentedToken, $record->token_hash)) {
abort(401);
}A refresh token MUST NOT be written to storage or logs in plaintext at any point after issuance.
Contract
Refresh rule
Concurrent 401s must not each trigger a refresh. The first acquires a single-flight lock and performs the refresh; every other awaits and reuses its result. On refresh, the refresh token rotates and the previous one is invalidated server-side, so a stolen refresh token has a bounded, single-use life.
let inFlight: Promise<Session> | null = null;
function refresh(): Promise<Session> {
// Concurrent callers await the SAME refresh, never start a second.
inFlight ??= performRefresh().finally(() => { inFlight = null; });
return inFlight;
}Contract
Refresh token theft detection
Because refresh tokens rotate and are single-use, the same token presented twice is a signal — either a lost race or a stolen token replayed alongside the legitimate holder. The server marks each refresh token consumed (or deletes it) atomically on first use: the first presentation succeeds and issues the next token; a second presentation of the same, now-consumed token MUST fail.
A confirmed reuse SHOULD invalidate the entire session family — every refresh token descended from the compromised one — forcing re-authentication. This is detection, not prevention: it cannot stop the theft, but it bounds the abuse window to a single rotation and surfaces the compromise. A legitimate client that merely lost a race re-authenticates; the tradeoff deliberately favours a false-positive logout over a silent hijack.
// First use consumes the token atomically (compare-and-set or delete-returning).
const consumed = await consumeRefreshToken(presented); // true only for the first caller
if (!consumed) {
// A second use of an already-consumed token is reuse — assume compromise.
await invalidateSessionFamily(presented.familyId);
throw new Unauthorized("refresh token reuse detected");
}Definition
Revocation
A session can be ended server-side before its access token expires. A stateless JWT is valid until expiry by construction, so revocation requires the server to hold authoritative revocation state and consult it on each check.
Contract
Revocation mechanism
Two mechanisms provide that state, at different granularities. A system MUST implement at least one. If both are implemented, apply the union — a token is revoked if either mechanism says so.
| Mechanism | Granularity | Tradeoff |
|---|---|---|
| Token version (per-user) | All of a user's tokens at once | Simple — one integer per user, bumped on logout-all or credential change. Coarse: cannot revoke a single device without ending every session. |
| Revocation list (per-token) | A single token or session | Precise — revoke one device or session by id/jti. More state: an entry per revoked token until it would have expired, plus a lookup per check. |
If both are implemented, a token is revoked when its per-user version is stale OR it appears on the per-token revocation list.
Definition
Known limitations
Revocation is not instant. Consulting the authoritative revocation store on every request would put it on the hot path of every authenticated call, so the check is cached. There is therefore a bounded window between a revocation being recorded and every node observing it, governed by cache TTL and invalidation propagation. That window MUST be bounded by a configurable TTL — default 60 seconds — which is the worst case even if an invalidation message is lost. Within it a revoked token MAY still be accepted for routine actions; sensitive actions MUST additionally perform a live check against authoritative server state (see the RBAC consistency model), so revocation takes effect immediately for dangerous operations regardless of the cache window.
Revocation-list growth. A per-token revocation list accumulates an entry per revoked token until that token would naturally have expired, and it MUST be pruned on that boundary or it grows without bound and slows the per-check lookup. The per-user token-version mechanism avoids this cost — trading it for coarser granularity — which is part of choosing between the two.
Invariants this spec guarantees
- No token is trusted before its signature, expiry, issuer, and audience are verified.
- A token at rest MUST be protected per its platform's threat model and MUST NOT be readable by untrusted code — web access tokens are delivered in HTTP-only cookies, and SPA refresh tokens use PKCE rather than localStorage.
- Server-side, refresh tokens MUST be stored only as a slow hash (bcrypt/Argon2); the plaintext is never persisted.
- Refresh is single-flight and single-use: it rotates the token and invalidates the prior one, and a reused token is rejected and invalidates the session family.
- At least one revocation mechanism (per-user token version or per-token revocation list) MUST be implemented; if both, a token is revoked when either applies.
- A revoked session MUST be rejected within a bounded TTL (default 60s) for routine actions, and immediately for sensitive actions via a live authoritative check.
Revision history: revised on 24 August 2026 to add per-user vs per-token revocation mechanisms, refresh-token reuse detection, hashed server-side refresh-token storage, platform-specific client storage guidance, and a bounded (default 60s) revocation-latency window. Conformance language follows RFC 2119.