40 lines
1.7 KiB
Markdown
40 lines
1.7 KiB
Markdown
# ADR-0008: Short access tokens, rotating refresh tokens, separate audiences
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-08-11
|
|
|
|
## Context
|
|
|
|
Storefront customers and back-office staff authenticate against the same API. A single
|
|
token type shared between them means an XSS on the storefront is a path into the admin.
|
|
|
|
## Decision
|
|
|
|
A short-lived (15 min) stateless JWT access token carrying the permission set, plus a
|
|
long-lived refresh token that is opaque to the client, delivered as an httpOnly SameSite cookie,
|
|
stored server-side only as a SHA-256 hash, and rotated on every use.
|
|
|
|
Rotation is tracked with `Session.replacedById`. Presenting an already-rotated refresh token
|
|
means the token leaked, so the entire token family is revoked — theft detection, not just theft
|
|
mitigation.
|
|
|
|
Every token carries an audience (`storefront` or `admin`). Admin controllers declare
|
|
`@RequireAudience('admin')`, and the check runs before any permission logic.
|
|
|
|
## Consequences
|
|
|
|
A stolen access token expires in minutes. A stolen refresh token is detectable and
|
|
self-revoking. A stolen storefront token is rejected by admin endpoints on audience alone,
|
|
before permissions are consulted.
|
|
|
|
The trade-off is that permission changes are not instantaneous — bounded by the access token
|
|
lifetime. For an immediate kill switch, `CACHE_KEYS.revokedSession` exists as a Redis
|
|
denylist checked per request; it is deliberately not enabled by default because it reintroduces
|
|
a hot-path lookup.
|
|
|
|
## Alternatives considered
|
|
|
|
Server-side sessions — simpler to revoke, but adds a datastore read to every
|
|
request and complicates a future mobile client. Long-lived access tokens — rejected: the blast
|
|
radius of a leak is unacceptable for a system holding payment and address data.
|