# ADR 0002: JWT access + refresh tokens with revocation ledger - **Status:** Accepted - **Date:** 2026-04-09 - **Deciders:** Platform team, Security ## Context Originally `/api/login` issued a single long-lived JWT (24h+) stored in `localStorage`. This gave us three problems: 1. **No revocation.** A leaked token was valid until it expired; there was no server-side way to invalidate it short of rotating the global JWT secret. 2. **Silent logouts.** When the token expired mid-session the browser just started receiving 401s with no graceful recovery path. 3. **Surface area.** Every endpoint accepted the same kind of token, so a token intended for a refresh use-case could be replayed as a full API credential. ## Decision Adopt a two-token flow: - **Access token** — 1 h TTL, stateless, carries `type: "access"`. Sent on every request as `Authorization: Bearer …`. `validate_token()` in `encoach_api.controllers.base` rejects tokens whose `type` is anything other than `"access"`. - **Refresh token** — 7 d TTL, carries `type: "refresh"` and a unique `jti`. Every issued refresh token is logged in a new `encoach.jwt.token` Odoo model (the revocation ledger) with fields for `user_id`, `issued_at`, `expires_at`, `last_used_at`, `revoked`, `user_agent`, `remote_ip`. Endpoints: - `POST /api/login` — returns `access_token`, `refresh_token`, `expires_in`. - `POST /api/auth/refresh` — validates the refresh token, revokes the old ledger row (rotation), and issues a fresh access + refresh pair. - `POST /api/logout` — revokes the supplied refresh token's ledger row. The frontend (`frontend/src/lib/api-client.ts`) handles rotation transparently: on 401 it calls `/api/auth/refresh` once (coalesced across concurrent requests) and retries the original request. If refresh fails, all tokens are cleared and the user is redirected to `/login`. A cron (`encoach_api.data.cron`) purges expired ledger rows daily. ## Consequences - Positive: revocation works — logout or compromise clears the server-side ledger entry and the refresh token is instantly unusable. - Positive: short access-token TTL limits the blast radius of a leaked Bearer. - Positive: the refresh flow is invisible to users; no more mid-session logouts. - Negative: one extra DB round-trip per refresh. Mitigated by the short-lived access token and the fact that the ledger is indexed on `jti` + `user_id`. - Follow-up: move ledger cleanup from a time-based cron to an event-based cleanup if the table ever grows past a few hundred thousand rows. ## Alternatives considered - **Opaque session tokens with a Redis store.** Rejected — adds an operational dependency (Redis) that the rest of the stack does not yet require, and complicates horizontal scaling. - **Single JWT with short TTL + silent re-login.** Rejected — requires the client to store credentials or an SSO cookie, neither of which we want in `localStorage`.