AUTH4D-304 refresh-token reuse window
POST /oauth/token with grant_type=refresh_token still rotates the refresh token on every
success (AUTH4D-11 (repository file docs/api-spec/auth4d-11.md), AUTH4D-63 (repository file docs/api-spec/auth4d-63.md)). What changes is how a second
presentation of the token a rotation just retired is answered. A refresh response lost on a mobile
network, a retry, two browser tabs or two server instances refreshing at the same moment no longer
sign the player out.
Behaviour
The window is fixed at 30 seconds (REFRESH_REUSE_GRACE_SECONDS in packages/sessions). It is
not configurable.
When refresh token T0 rotates to T1:
- Presenting
T0again within 30 seconds of that rotation returns200with a new token set. Its refresh tokenT2is a sibling ofT1in the same family: the same session, scopes, client, idle expiry rules and absolute expiry. The session stays active andT1keeps working. - Each rotation allows at most three siblings beyond
T1(REFRESH_REUSE_MAX_SIBLINGS). A fourth in-window reuse ofT0returns400 invalid_grant, issues no tokens and does not revoke the family. - When any live sibling (
T1,T2, …) is presented and rotates, the others are retired. Presenting a retired sibling later is a replay. - Only the immediate predecessor has a window. Presenting
T030 seconds or more after the rotation, or any token two or more rotations old, is a replay.
A replay is answered exactly as before: 400 invalid_grant, and the whole refresh family and its
session are revoked, including every live sibling and the session behind outstanding access
tokens. The family must also still be live: a revoked or expired family, a revoked session or
another client’s token is refused with invalid_grant whether or not it is inside a window.
All other responses are unchanged, including Cache-Control: no-store on every response.
Example
POST /oauth/token refresh_token=T0 -> 200, refresh_token=T1
POST /oauth/token refresh_token=T0 (+10 s) -> 200, refresh_token=T2 (sibling of T1)
POST /oauth/token refresh_token=T2 (+60 s) -> 200, refresh_token=T3 (T1 is retired)
POST /oauth/token refresh_token=T1 (+61 s) -> 400 invalid_grant (family and session revoked)
Storage
The session authority Durable Object (packages/sessions/src/authority-do.ts) keeps one
auth4_refresh_grace row per family, keyed by (tenant_id, family_id): the hash of the token the
latest rotation retired, the end of its window, and the hashes of the live successor and its
siblings. The table is created with CREATE TABLE IF NOT EXISTS, so no D1 migration is needed,
and rows are removed by the existing cleanup alarm when their family expires. Every check and
update happens inside the rotation’s single transactionSync. Only SHA-256 token hashes are
stored, and nothing about tokens is logged.
Audit
Refresh does not write audit events, and the window does not add any. An in-window reuse records no replay or revocation. Replays outside the window revoke the family and session as before.
SDKs
@auth4/nextjs keeps sharing one refresh result per server instance for 60 seconds. Combined with
the server window, several instances refreshing one session at the same moment no longer sign the
player out. The browser, Unity, Godot and Unreal SDKs need no changes.
Source: docs/api-spec/auth4d-304.md