Skip to main content
Why there are two tokens, why the refresh token is stored, and what happens when one leaks.

The shape of a session

Signing in returns three things: the user, a short-lived access token and a longer-lived refresh token.
  • The access token is a JWT, signed with JWT_ACCESS_SECRET, valid for 15 minutes. Every request carries it; the server verifies the signature and nothing else, so no database round trip sits in front of each call.
  • The refresh token is opaque, stored in the database with the user’s sessions, and valid for 7 days. It exists only to mint new access tokens.
Both tokens are signed with different secrets, so a leaked access-token secret cannot be used to mint refresh tokens.

Why the refresh token is stored

A JWT alone cannot be withdrawn: it is valid until it expires, and nothing on the server knows it was revoked. Storing the refresh token makes withdrawal possible — which is what makes sign-out real. The refresh flow therefore costs a database lookup, once every 15 minutes per session, not once per request. That is the trade: a cheap hot path and a revocable cold one.

Rotation

Every refresh rotates the pair:
  1. The presented refresh token is looked up and revoked.
  2. A new pair is issued, and the new refresh token is returned so the client can replace the one that was just consumed.
A token can therefore be exchanged exactly once. If a stolen token is replayed after the legitimate client has already refreshed it, the replay is rejected as session_expired — and the anomaly is visible. This is the standard mitigation for a stolen refresh token: the attacker and the owner race, and the loser gets an error rather than a silent second session.

Sign-out

auth.signOut revokes the refresh token presented in the body. It is idempotent, because a user who clicks twice, or whose token expired a moment earlier, should not see an error. The access token remains valid until it expires — up to 15 minutes. Revoking it immediately would mean a database lookup on every request, which is the cost the two-token design exists to avoid. A stolen access token is therefore usable for the remainder of its lifetime; a stolen refresh token is usable at most once.

Where the tokens live in the browser

Both tokens are held in a persisted Zustand store under one key, and sent as an Authorization header. No cookies, so no CSRF surface and nothing for a sibling route to read. The trade is explicit: localStorage is readable by any script that runs on the origin. This is acceptable here because there is no payment data and the refresh-token rotation already limits the blast radius. An application handling higher-value credentials should move refresh tokens to an HttpOnly, Secure, SameSite=Strict cookie and keep only the access token in memory. See client state.

Refresh on the client

The HTTP client refreshes once and replays the original request:
  • A 401 triggers a refresh.
  • The rotated pair returned by the refresh replaces both stored tokens — the refresh token in the store is the one the server just consumed, so keeping it would fail the next refresh with session_expired.
  • Concurrent 401s share one refresh, so a screen firing five queries does not fire five refreshes.
  • If the refresh fails, the session is cleared and the router sends the user to sign in, remembering where they were.

Public endpoints

Everything requires a token except auth.signIn and auth.refresh. The guard is global and opt-out (@Public()), so a new endpoint is protected unless someone deliberately opens it — the safe default.
Last modified on October 6, 2026