ADR-0013: Sessions and refresh tokens — revocable auth, rotation, and one door into “logged in”
- Status: Accepted — promoted to core 2026-09-10 (from
expenses/backend/) - Date: 2026-07-22
- Scope: core (any backend with its own sign-in, and the clients that call it)
Promoted per 0009-one-knowledge-monorepo once a second project (dracomania) adopted
it. The decision below is the generic part; How the projects apply it records where
each project chose differently. Section numbers are unchanged from the Expenses
original, because code comments and other docs cite them (§2, §3, §5, §7, §8).
Context
A backend whose only credential is a signed access token asks that token to do two
incompatible jobs, and the tension has no solution while there is only one of them:
- Short TTL ⇒ the user re-authenticates constantly.
- Long TTL ⇒ an issued token cannot be stopped. With no
jti, no token version and no
denylist, nothing kills a session beforeexp.
One token also leaves nothing to revoke on logout (logout is a client-side forget), and no
answer to “someone has my phone”. And every later way of signing in (a second factor,
federated login, a guest account) must end in the same place, or each becomes a way around
the others.
Origin (Expenses). The access JWT was the entire authentication system: 24h, no second
token, no refresh path, no revocation, no logout endpoint. A mobile user complained about
typing their password every day. 2FA existed on the backend with no client implementing it,
and Google sign-in was planned, so a refresh path bolted on independently would have become
a way around 2FA.
Decision
1. Two tokens: a row per refresh token, a family per sign-in
An access token (JWT, validated by signature, never looked up) and a refresh token
(opaque, long-lived, backed by one sessions row). Every token descended from one sign-in
shares a family_id. The refresh token is sent only to /auth/refresh and
/auth/logout.
The refresh token is deliberately not a JWT: 256 bits of randomness with no claims. It
cannot be mistaken for a bearer token by a filter, and it means nothing without the row
backing it, which is what makes revocation possible at all.
Stored as SHA-256, not BCrypt. Unlike a password this value is full-entropy random, so
there is nothing to brute-force and no reason to pay a work factor on a lookup that happens
on every refresh. The requirement is only that a database leak yields something unusable.
This does not make the API stateful in the REST sense. No per-connection or
per-instance state exists, SessionCreationPolicy.STATELESS is unchanged, and the
authenticated request path never touches the table. Revocation and pure statelessness are
simply incompatible (you cannot revoke a token you do not track), and a row per refresh
token is the cheapest possible version of that trade.
2. Rotation, reuse detection, and a grace window
Every refresh spends the presented token (ROTATED) and issues a successor in the same
family_id. A spent token presented again can only mean it was copied, so the entire
family is revoked (REUSE_DETECTED): thief and victim both stop, and the victim simply
signs in again.
Rotation has a failure mode that must be designed for, not discovered: several requests
legitimately racing with the same token. Clients close this with single-flight refresh
(§7), but a suspended tab or a browser without Web Locks can still race. So a token
re-presented within a 10-second grace of its rotation issues another sibling instead of
tripping detection. Past that window, reuse is theft.
The revocation commits in its own REQUIRES_NEW transaction. Detection ends by
throwing, and the throw would otherwise roll the revocation back with it, leaving a stolen
family alive while the response claimed otherwise. (Found in Expenses by executing the flow,
not by reading it; see Verification.) The general rule, for any write a refusal must not
undo, is 0002-clean-architecture-and-use-cases’s 2026-09-10 amendment.
3. IssueSessionUseCase is the single door into “logged in”
Every way in calls it: password login, a registration that signs the user in, a completed
second factor, federated sign-in. A login that still owes a second factor mints nothing
(no access token and no refresh token) until the factor passes.
A session therefore only exists downstream of a completed authentication by
construction, not by a check somebody has to remember to write in each new entry point.
This is the property that keeps refresh from becoming a 2FA bypass.
4. The access token names its session family in a sid claim
The access token carries sid = the session’s family id (stable across rotations; the
row id is not). A project with a device list uses it to answer “which of these devices is
me” and to spare the caller from “sign out everywhere else”.
The point is that managing sessions never requires sending a refresh token outside the
two endpoints in §1.
5. The refresh token travels in the request body — never a cookie
One contract for every client: POST /auth/refresh {refreshToken} →
{accessToken, refreshToken}. The backend does not know or care whether the caller is a
browser.
A cookie would have made the API browser-aware: per-environment Domain config, CSRF back
on the table (it is disabled deliberately for a stateless API), and a second code path for
native clients that cannot use one. We keep session mechanics out of the backend’s
contract.
6. Where clients keep tokens
- A native client keeps the refresh token in the platform’s secure store and the access
token in memory only. The access token is short-lived and read on every request, so a
secure-store read per call buys nothing, and keeping it out of the store is the
prerequisite for gating that store behind biometrics without a prompt per fetch. - A web client has no equivalent store. A long-lived credential in JS-readable storage
means an XSS yields persistent access rather than an access token’s lifetime of it. A
project that accepts this says so plainly, with its mitigations. It is reversible: moving
to a cookie later changes where the client puts a string, not the endpoint (§5).
Each project records where its own clients keep tokens, and any trade-off it accepts, in
its own unpublished docs. This page states the rule, not any project’s security posture.
7. Refresh-and-replay lives in the client’s API layer, once
A 401 refreshes and replays the original request, so callers never see the expiry. This
belongs in the client’s single API instance (core
0013-centralized-api-layer-and-server-state), never per call site. Two races must be
closed or the server’s own reuse detection (§2) will sign users out for nothing:
- within a client — a screen mounting fires several queries at once; they all 401
together. Collapse them into one in-flight refresh. - across browser tabs — serialise with
navigator.locks; the tab that waits re-reads
storage first and finds the winner’s fresh token.
Calls that cannot go through the shared instance (streaming, multipart upload) get an
explicit equivalent rather than an exemption — otherwise they are the only screens that
still drop a user at the login page.
This binds every client of this design: it is the client half of §2’s contract, not a
client preference. Expenses: the axios instance in expenses-web, with explicit
equivalents for its SSE chat stream and its multipart uploads.
8. Lifetimes, and the access TTL
- Two refresh deadlines. An idle deadline moves forward on every rotation; an absolute
cap is fixed at sign-in and inherited by every successor, so refreshing forever is not a
way to outlive it. A session dies at whichever comes first. - The access token is short (about 15 minutes) once every client refreshes. A project
whose clients refresh from their first release sets it from day one. A project moving off
a long-lived token shortens it only once every client demonstrably refreshes in
production: shortening it before a client has a refresh path would make that client sign
out more often, the opposite of the goal. Either way it is configuration, not code.
Both projects use a 30-day idle and a 90-day absolute deadline. Each project’s access TTL,
and where it stands on shortening it, belong in its own docs.
9. SessionStatus is the lifecycle source of truth
ACTIVE / ROTATED / REVOKED as an enum column, per core
0001-status-as-source-of-truth — never inferred from nullable timestamps. Expiry is
deliberately not a status: it is a function of the deadlines and the clock, so nothing has
to write a row for time passing.
How the projects apply it
Both backends implement §1–5 and §9 as written: SHA-256 hashes, family_id, the 10-second
grace, a dedicated REQUIRES_NEW revocation bean, IssueSessionUseCase, the sid claim and
the body contract. Where they differ:
| Expenses | Dracomania | |
|---|---|---|
| Credentials live on | the users row | a separate accounts table, one-to-one with the public players row |
| Device list and per-device sign-out | yes (GET / DELETE /auth/sessions), with device columns on sessions | no; the rows exist only to keep a player signed in and to be revocable, so there are no device columns |
| The principal on each request | reloaded from the database, so a role or status change applies at once | built from the token’s claims with no database load, so a role change applies at the next refresh |
| Minting the access token | IssueSessionUseCase imports the infrastructure JwtService | through an application port, AccessTokenIssuer |
| Other doors through §3 | the second factor (0014-two-factor-single-use-challenge) | registration signs the new player in |
| Dead rows | purged nightly after a 30-day retention | kept; no purge is built |
| Migration | V63__sessions.sql | V5__create_accounts_sessions_and_password_reset_codes.sql |
Dracomania records why in 0007-accounts-sessions-and-access-by-actor (its 2026-09-10
amendment, “the first cut, as built”).
Consequences
- Revocation exists.
POST /auth/logoutis real, and a stolen refresh token stops at the
victim’s next refresh. A project with a device list also makes a lost phone answerable;
sessions the user cannot see would be worth little there, so the list is part of the
feature, not a follow-up. - Any new authentication path must mint through
IssueSessionUseCase. This is the rule
federated sign-in, second factors and guest accounts are held to. /auth/refreshis a new brute-force surface. Rate limiting stays at the edge per
0007-rate-limiting-at-the-edge — reviewed explicitly rather than by reflex, and
unchanged: guessing a 256-bit opaque token is not a realistic attack, and the endpoint’s
own reuse detection punishes replay.- Spent and revoked rows are the detection window. Delete them eagerly and a stolen
token stops being recognisable as theft and starts looking merely unknown. A purge, where a
project runs one, keeps them for a retention period first. - A client presenting an invalid, expired, revoked, or replayed token gets the same
INVALID_REFRESH_TOKEN401 for all four. Distinguishing them would hand an attacker a
probing oracle, and the client’s reaction is identical anyway. - A client refreshes before it renders, so launch never shows a signed-in screen whose
queries then 401 and bounce to the login page. (Expenses’ mobile app changed its launch
path to do this.) - Expenses: the backend change was purely additive on the wire (
accessTokenstayed
where it was), so clients that ignoredrefreshTokenkept working through the rollout.
Verification (Expenses)
Query-level correctness here is not observable by reading, so the flow was exercised
against a running backend: rotation; a spent token replayed outside the grace window
revoking the whole family; 8 concurrent refreshes of the same token producing zero false
theft and exactly one rotation; logout idempotency; per-device revoke; a suspended account
refused a refresh. The REQUIRES_NEW rollback bug in §2 was found this way — the endpoint
returned a correct-looking 401 while the revocation silently rolled back.
A new implementation is worth the same exercise; the replay and the concurrent refreshes
are the checks that caught something.
Related
- 0001-status-as-source-of-truth —
SessionStatusas the lifecycle enum - 0002-clean-architecture-and-use-cases — its 2026-09-10 amendment states §2’s
REQUIRES_NEWrule in general form - 0013-centralized-api-layer-and-server-state (core frontend) — where refresh-and-replay
lives - 0007-rate-limiting-at-the-edge — reviewed for
/auth/refresh, unchanged - 0018-resource-access-session-scoped-or-actor-declared — how the caller this ADR
identifies is then authorised - 0010-schema-changes-via-migrations — Expenses
V63, dracomaniaV5 - 0007-accounts-sessions-and-access-by-actor (dracomania) — the second implementation,
and where it departs - Expenses follow-ups this ADR deliberately left open — since realised: biometric app lock
(mobile) 0008-biometric-app-lock and 2FA client support (all three clients)
0014-two-factor-single-use-challenge; 0015-account-deletion-grace-and-personal-data-purge
completes that overhaul. Still open there: federated sign-in and the access-TTL reduction. - Where the code lives:
docs/private/reference-implementations.md(unpublished)