Token Service Low-Level Design: JWT Issuance, Refresh Token Rotation, and Revocation

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:

  1. Fetch the public key from the JWKS endpoint (/.well-known/jwks.json), keyed by kid. Cache it.
  2. Verify the RS256 signature using the public key
  3. Check exp > now() (not expired)
  4. Check aud includes this service's identifier
  5. Check iss matches the trusted issuer
  6. Optionally check jti against 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:

  1. Look up the token by hash. Verify it is not expired and not revoked.
  2. Check used_at IS NULL. If already used → token reuse detected (see theft detection below).
  3. Mark the old token used_at = now().
  4. Issue a new access token + a new refresh token with the same family_id and parent_token_id = old token.
  5. 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: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

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

Scroll to Top