Skip to content

fix(auth): give the rotated access token a fifteen-second sunset - #929

Merged
MBombeck merged 1 commit into
mainfrom
fix/rotation-sunset
Sep 6, 2026
Merged

fix(auth): give the rotated access token a fifteen-second sunset#929
MBombeck merged 1 commit into
mainfrom
fix/rotation-sunset

Conversation

@MBombeck

@MBombeck MBombeck commented Sep 6, 2026

Copy link
Copy Markdown
Owner

A native client with several requests in flight at the 24 h boundary sends some of them with the old access token a few hundred milliseconds before the refresh completes. The rotation revoked that token the instant it committed, so those requests came back 401 revoked; the client read that as "sign in again", rotated a second time with the refresh token it had just consumed, tripped reuse detection, and the whole family was revoked. Signed out for having been busy. The audit trail shows it as auth.token.refresh followed immediately by auth.bearer.failure reason=revoked.

The paired access token now gets a short sunset instead of an instant revoke: its expiry becomes the earlier of its own expiry and now plus fifteen seconds, in one parameter-bound update with LEAST, so there is no read-then-write window and a token that would have expired sooner keeps its sooner expiry. The bearer check already rejects an expired token, so nothing changes on the read side beyond the reason.

Untouched on purpose: reuse detection. A replayed or device-mismatched refresh token still revokes the family and its access tokens instantly; that path is the stolen-token defence and must not gain a window. Logout still revokes instantly.

Reproduced first against the current code, which failed at the first assertion with revoked. Four cases: accepted inside the window and expired after it, replay still instant, a five-second expiry stays five seconds, logout instant. Restoring the instant revoke turns three tests red; dropping the minimum turns the five-second case red against real Postgres. The refresh endpoint's contract text never described the paired token's fate, so it is unchanged.

Rotation revoked the access token paired with the consumed refresh token
the instant the rotation committed. A native client with requests already
on the wire got 401 revoked on those, read that as "re-authenticate",
rotated again with the refresh token it had just spent, and tripped reuse
detection: the device's whole token family died because the client was
busy at the boundary.

Pull the paired token's expiry in to fifteen seconds instead. It is long
enough for an in-flight request on a slow mobile link to finish, including
a retry, and short enough that the token gains nothing meaningful over the
validity it already had. bearer.ts answers a passed expiry with reason
expired, which a client treats as "refresh", not as "sign in again".

LEAST(expires_at, $1) takes the minimum inside the single UPDATE, so the
write can only ever shorten: a token already inside its last seconds keeps
its own expiry, and there is no read-then-write window. The bound value is
an ISO-8601 UTC string cast in SQL so the comparison does not shift with
the host's timezone.

Reuse detection is untouched. A replayed refresh token, or one presented
under a spoofed device id, still revokes the family and its access tokens
on the spot, and so does the logout path.
@MBombeck
MBombeck merged commit fe71c9b into main Sep 6, 2026
24 checks passed
@MBombeck
MBombeck deleted the fix/rotation-sunset branch September 6, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant