25
Backend
04
Security
08
iOS
07
Infra

Use device-bound PASETO tokens as the only session state

ADR-0028 ACCEPTED · 2025-07-30
Use device-bound PASETO tokens as the only session state

Supersedes ADR-0019, ADR-0020 and ADR-0025. Absorbs ADR-0027.

Decision

Drop the server-side session store. For the app, the PASETO v4.local bearer token is the session. It identifies the user and the device and carries nothing else, and its footer names the encrypting key by PASERK lid. Permissions stay in Postgres, and the server loads the user on every request, so a permission change applies from the next request. MCP clients authenticate separately, with OAuth refresh tokens (ADR-0063).

The app generates a device id, keeps it in the Keychain and sends it with every request, and the server rejects a token that arrives with a different one. That is weaker than DPoP (RFC 9449): a stolen token alone fails, a stolen token plus the device id works.

Tokens last 90 days and slide forward with use. When a token is more than a day old or was encrypted with a deprecated key, the server reissues it in an X-New-Token response header and the app's Apollo interceptor stores it. There is no refresh endpoint and no refresh token. Ninety days is the longest we're willing to defend without a revocation list. Deprecated signing keys keep validating for the same window, so rotating keys never signs anyone out.

Why

The app started on cookie sessions through tower-sessions and axum-login, and on iOS they lost sessions with no error. ADR-0019 moved the transport to a bearer token but kept the Redis session behind it, and ADR-0020 ran tokens and cookies side by side for a future web client. The session crates rewrote and deleted session state under the token in ways we couldn't control without forking them. ADR-0025 had set a 365-day lifetime; we cut it to 90 once nothing on the server could end a session.

The session layer had survived on a web developer's wariness of long-lived bearer tokens. On iOS the token lives in the Keychain, native HTTP clients expect a bearer header, and OWASP's mobile guidance accepts long session timeouts because people use apps in bursts over months. The web client was hypothetical, so the session layer was cost with no user.

Rejected alternatives

  • JWT. A JWT names its own algorithm in the alg header, the root of algorithm-confusion attacks; PASETO has no such header. v4.local also encrypts the payload, and PASERK standardises the key identifier JWT leaves to each implementation.
  • Keep Redis sessions behind the token. The source of the races, and built for browsers.
  • A refresh endpoint with a separate refresh token. An extra round trip, a longer-lived credential on the device, and a client that must decide when to call it. The header rides on responses the server already sends.
  • Roles or permissions in the claims. Stale for up to 90 days.
  • Short sessions. People plan months ahead and open the app in bursts.
  • A revocation list now. It brings back per-request server state.

Consequences

Authenticating a request is a decrypt, an expiry check, a device-id comparison and one user query. There is no per-user revocation. The only way to end sessions early is to revoke the signing keys, which ends all of them. A client that ignores the refresh header keeps its token until it expires, then signs in again. A web client would need its own session design.