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 the secret lives, and how a session is ended. Every clause closes a specific, common bypass.
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.Definition
Storage rule
Tokens live in the platform's hardware-backed secure store — Keychain, Keystore/EncryptedSharedPreferences — never in a plaintext database column or an unprotected preference. Access and refresh tokens are written as a single atomic record so a suspend or crash mid-write cannot leave a torn state that logs the user out.
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;
}Definition
Revocation
A session can be ended server-side before its access token expires, via a revocation list or a per-user token version bumped on logout or credential change. Authorization for sensitive actions re-checks against current server state rather than trusting a role snapshot taken at login, so a revoked privilege takes effect before the next dangerous operation.
Invariants this spec guarantees
- No token is trusted before its signature, expiry, issuer, and audience are verified.
- Secrets are stored only in the hardware-backed secure store, written atomically — never in plaintext at rest.
- Refresh is single-flight and rotates the refresh token, invalidating the prior one.
- A revoked session is rejected before its access token would naturally expire.