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

Source: https://www.techinterview.org/post/3233469525/lld-token-service/
Updated: 2026-06-30 · techinterview.org

## 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 by `kid`. Cache it.

- Verify the RS256 signature using the public key

- Check `exp > now()` (not expired)

- Check `aud` includes this service's identifier

- Check `iss` matches the trusted issuer

- 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:

- 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_id` and `parent_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: [Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence](https://www.techinterview.org/post/3233460274/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](https://www.techinterview.org/post/3233460275/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](https://www.techinterview.org/post/3233460268/stripe-interview-guide-2026-process-bug-bash-round-and-payment-systems/)
