The token model

Two credentials, and only one of them is worth stealing for long

The access token is a signed JWT. It is short-lived, it is presented as Authorization: Bearer, and nothing revokes it — it simply expires. A service that wants to check one without calling your Worker verifies that signature itself. That is the trade: a token nothing has to look up is a token every route can accept without a database round-trip.

The refresh credential is a session row. It lives in pithy_auth_sessions, it lasts seven days by default, and it is revocable because revoking it is a DELETE. A web client carries it as a cookie; a mobile client carries it as a bearer token.

Everything interesting on this page is about the second one.

Rotation

POST /auth/token/rotate takes the refresh credential you hold and gives you back a new one, along with a fresh access token:

{
  "accessToken": "eyJhbGciOi…",
  "refreshToken": "…",
  "expiresAt": "2026-09-03T12:00:00.000Z"
}

The old refresh credential stops working the moment the new one exists. This is the half a mobile client has to implement, because it holds the pair itself rather than a cookie the browser manages. That is the point of rotating rather than reissuing: a stolen refresh token has a useful life bounded by how long it is until the real client next refreshes, instead of by the session lifetime.

Better Auth does not do this natively. The route is the kit’s.

What happens when a used token comes back

This is the part that earns the design. A refresh token that no longer resolves to a live session, but which the ledger says was consumed earlier, is a replay — and a replayed refresh token is the recognized compromise signal for rotated refresh credentials.

The kit’s answer depends on how long ago:

When the replay arrivesWhat happens
Within 30 seconds of the consumeDenied, and nothing else. The family survives
After 30 secondsThe whole family is revoked — every live session sharing the id is signed out — and an audit event is written

The grace window is not a weakened defense; it is what makes the defense usable. A client that fires the same rotation twice — a retry after a flaky response, two tabs waking at once — is not an attacker, and a system that signs somebody out of everything for a network race is a system whose users learn to distrust it. Thirty seconds separates my connection hiccuped from somebody kept a copy.

A “family” is the chain. Every rotation carries the family id forward, so a session that has rotated forty times is still one family — and revoking it revokes all of it, wherever the chain has reached.

Two races, closed deliberately

One presented token yields exactly one successor. The consume is a conditional DELETE … RETURNING: of any number of concurrent rotations presenting the same token, exactly one deletes the row and wins. The losers delete the successor they had already minted and answer 401. There is no window in which one token has produced two live sessions.

The successor is minted before the old one is consumed. If signing fails transiently, the caller is left holding a refresh token that still works and an unused successor that will expire on its own — rather than holding nothing at all, which is the failure mode of consume-then-mint.

Lifetimes

The defaults, and the config that changes them, are in the reference.

SettingDefaultWhat it governs
sessionExpiresIn7 daysThe refresh credential’s life. Slides forward on use
sessionUpdateAge1 dayHow often an active session’s expiry slides
verificationExpiresIn5 minutesA magic link or an OTP. Single-use regardless
otpLength6Digits in an emailed code

The access token’s lifetime is Better Auth’s, not something this capability sets.

What the app Worker actually does per request

It resolves the session; it does not verify a JWT locally. The session middleware runs on every request carrying an Authorization or Cookie header, asks Better Auth for the session, and fills c.var.auth — the seam every other capability reads. A request with neither header does none of this: no instance build, no database hit, and it stays anonymous.

Resolution never throws. An invalid credential leaves c.var.auth null, and it is requireAuth() on a protected route that turns that into a 401. Failing at the guard rather than at resolution is what lets a public route stay public for a caller whose token happens to be stale.

The user’s stored locale rides along on the same lookup, because the session query already loaded the user row — which is what makes a reader’s saved language outrank their browser’s Accept-Language.

ESC