Two-Token Architecture
Modern authentication uses two token types with different properties:
- Access token: Short-lived (15 minutes), self-contained JWT. Resource servers verify it by checking the signature — no database round-trip required. Short expiry limits damage from theft.
- Refresh token: Long-lived (30 days), opaque random string stored in the database. Used only to obtain new access tokens from the token service. Never sent to resource servers.
JWT Structure
A JWT has three base64url-encoded parts separated by dots: header.payload.signature.
Header: { "alg": "RS256", "kid": "key-2024-01", "typ": "JWT" }
Payload: {
"sub": "user:12345",
"iss": "https://auth.example.com",
"aud": ["https://api.example.com"],
"exp": 1705328400,
"iat": 1705327500,
"jti": "a1b2c3d4-uuid",
"roles": ["editor", "viewer"]
}
Signature: RS256(base64url(header) + "." + base64url(payload), private_key)
jti (JWT ID) is a unique identifier per token, used for revocation. kid identifies which public key to use for verification, enabling key rotation.
JWT Verification at Resource Servers
Resource servers verify access tokens without calling the token service:
- Fetch the public key from the JWKS endpoint (
/.well-known/jwks.json), keyed bykid. Cache it. - Verify the RS256 signature using the public key
- Check
exp > now()(not expired) - Check
audincludes this service's identifier - Check
issmatches the trusted issuer - Optionally check
jtiagainst the revocation denylist
Refresh Token Storage
refresh_tokens(
id UUID PRIMARY KEY,
token_hash TEXT UNIQUE, -- SHA-256 of the opaque token
user_id UUID,
family_id UUID, -- all tokens from same login share this
parent_token_id UUID, -- forms a chain
expires_at TIMESTAMPTZ,
used_at TIMESTAMPTZ,
revoked BOOLEAN DEFAULT FALSE
)
The raw token is never stored — only its SHA-256 hash. Lookup: SELECT * FROM refresh_tokens WHERE token_hash = SHA256(:presented_token).
Refresh Token Rotation
On each use of a refresh token:
- Look up the token by hash. Verify it is not expired and not revoked.
- Check
used_at IS NULL. If already used → token reuse detected (see theft detection below). - Mark the old token
used_at = now(). - Issue a new access token + a new refresh token with the same
family_idandparent_token_id = old token. - Return both tokens to the client.
Single-use enforcement: a refresh token can be used exactly once. After rotation, the old token is permanently consumed.
Theft Detection via Family Tracking
If a stolen refresh token is used after the legitimate client has already rotated it, the attacker presents an already-used token. The token service detects used_at IS NOT NULL and responds by revoking the entire token family:
UPDATE refresh_tokens
SET revoked = TRUE
WHERE family_id = :family_id
This forces the legitimate user to re-authenticate. It may cause a false alarm if a legitimate request was retried, so the revocation response should include a clear re-login prompt.
Access Token Revocation
JWTs are stateless — they cannot be individually revoked without a lookup. For logout or compromised token scenarios, add the jti to a Redis denylist:
SET revoked:{jti} 1 EX {seconds_until_token_expires}
Resource servers check the denylist on each request: EXISTS revoked:{jti}. The TTL matches the token's remaining lifetime so the denylist self-cleans. This adds one Redis lookup per request — acceptable for security-sensitive endpoints.
Key Rotation
RS256 private keys are rotated every 90 days:
- Generate new key pair, assign new
kid - Add new public key to the JWKS endpoint (alongside old key)
- Start signing new tokens with the new private key
- Keep old public key in JWKS until all tokens signed with the old key have expired (15 minutes)
- Remove old key from JWKS
Resource servers cache JWKS with a short TTL (5 minutes) and re-fetch on unknown kid. This makes rotation transparent to resource servers.
Minimal Claims Principle
Put only what resource servers need for authorization decisions in the JWT payload. Avoid: email, display name, phone number, or other PII. These are readable by anyone with the base64-decoded token (JWT payload is not encrypted, only signed). Resource servers that need user profile data should query the user service, not read it from the token.
See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering
See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems