Skip to content

fix(auth): replay a refresh token within the 30s grace period - #137

Merged
gjtorikian merged 8 commits into
mainfrom
fix/refresh-token-replay-grace
Oct 15, 2026
Merged

gjtorikian merged 8 commits into
mainfrom
fix/refresh-token-replay-grace

Conversation

@gjtorikian

@gjtorikian gjtorikian commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #131

Problem

From the issue, by @davidfou:

We use authkit-react-router with the emulator for local development. We noticed occasional unexpected logouts during development.

After investigating, we found that hot reload can trigger multiple concurrent requests, all of which attempt to refresh the same session. Because the emulator immediately invalidates the refresh token after it is used, only the first request succeeds. The remaining requests receive an invalid_grant response, which can cause the user to be logged out.

When testing the same scenario against the WorkOS staging environment, both refresh requests succeed and converge on the same session and refresh token, thanks to the 30-second grace period.

The refresh_token grant on POST /user_management/authenticate deleted the presented token as soon as it rotated it, so every concurrent or retried refresh after the first got invalid_grant and the SDK tore the session down.

Behavior

The public contract is the Session resilience docs, "The replay grace period":

AuthKit applies a short grace period after a refresh token is exchanged. Within that period, replaying the just-exchanged token returns the same rotated tokens as the first exchange rather than an error. This means concurrent refreshes and network retries converge on one result instead of tearing down the session. The grace period is 30 seconds.

The mechanics are production's resolveExchangedToken (packages/api-services/src/userland-sessions/userland-session-tokens.service.ts): the window is anchored on the successor's exchange, not the presented token's; a retired token whose successor has never been used is never retired; the chain walk is capped at 50 links; the refusal is invalid_grant / Refresh token already exchanged.. The length comes from a feature flag there (code default 0 ms), so the emulator uses the documented 30 seconds.

What the emulator does now:

  • A retired token whose successor has not been used replays indefinitely. That is the client whose rotation response was lost. The replay answers 200 with the same rotated refresh_token the first exchange returned, a freshly minted access token for the same session (sid unchanged), the same user / organization_id context, and the authentication_method a normal refresh reports. No new session, no session.created, no authentication.* event.
  • Once the successor is itself exchanged, the old token stays replayable for 30 seconds more, to absorb requests already in flight, then is terminally invalid.
  • A client several rotations behind converges on the newest live token. With A → B → C, replaying A or B returns C. A token more than 50 links behind is retired rather than walked, as in production.
  • Past the window, or once the chain's live end is gone (expired), the replay answers {"error": "invalid_grant", "error_description": "Refresh token already exchanged."}. The row is kept, so a second replay answers the same way rather than as an unknown token.
  • A revoked session (sessions/revoke, sessions/logout) fails every token of the session with the unknown-token shape, Invalid refresh token., checked before the token's own state, and deletes the whole chain, retired links included, since no rotation will ever sweep it.
  • A replay that also carries organization_id mints the access token for that organization and reports it, but the refresh token handed back keeps the organization the original exchange bound it to. Production's replay returns before the exchange hook that records the session's organization, so the switch is not persisted and a plain refresh of that token is back on the previous organization.
  • Rotations sweep. Each rotation deletes the session's retired tokens whose successor was exchanged more than 30 seconds ago or is gone, so a refresh does not leave a row behind forever. A link a replay could still reach is never swept: anything older in a chain than a swept token has a successor exchanged earlier still.

What changed

  • src/workos/entities.ts: WorkOSRefreshToken gains exchanged_at and exchanged_for_id, internal bookkeeping never serialized into a response.
  • src/workos/routes/auth.ts:
    • REFRESH_TOKEN_REPLAY_GRACE_MS (30 s) and REFRESH_TOKEN_REPLAY_MAX_CHAIN_DEPTH (50), each documented with its source, plus withinReplayGrace, replayableRefreshToken (the chain walk) and replayGraceClosed (the sweep predicate, the same check the walk makes first).
    • The refresh_token branch checks the session still exists before anything else (deleting every token of a session that is gone), then resolves a retired token to the live end of its chain or refuses it as already exchanged. The presented token is marked for retirement instead of deleted.
    • The shared response tail hands back the chain's live token on a replay instead of inserting another, and only after every check has passed stamps exchanged_at / exchanged_for_id on the token it rotated, so a refresh that fails leaves the token live to retry. It then sweeps the session's retired rows whose window has closed.
  • README.md: the "Refresh tokens always rotate" section is now "Refresh tokens rotate, with a 30-second replay grace period" and describes the above; the failure-shape table row says "already exchanged".
  • scripts/gen-supported-lib.ts + regenerated SUPPORTED.md: the Authentication note no longer claims the emulator is stricter than production here.

Tests

src/workos/routes/auth.spec.ts:

  • The existing rotation test uses the rotated token, advances the clock 31 s (setSystemTime), then asserts the old token is refused with Refresh token already exchanged.
  • Two refreshes of the same token started concurrently (Promise.all) both return 200, the same refresh_token, the same user.id and organization_id, access tokens with the same sid, authentication_method of the original sign-in; session count, session.created and authentication.* events do not grow and only two tokens exist in the store.
  • Once the successor was used more than 30 s ago, the replay is 400 / Refresh token already exchanged., the row is kept so a repeat answers the same way, and the live token still rotates; that rotation sweeps the original token but not the one whose successor was only just exchanged.
  • A retired token whose successor was never used still replays ten minutes later.
  • Chain A → B → C: replaying A or B returns C. After 31 s and a rotation of C to D, A is swept, B remains (its successor C was only just exchanged) and B replays to D.
  • A token 51 rotations behind is refused; one 50 behind replays (the cap is inclusive).
  • Replaying for a revoked session with a three-link chain fails with Invalid refresh token. and empties the chain, the never-presented link included; the live token fails the same way.
  • A replay that switches organization returns the same rotated token, reports the new organization_id with its role claim and the same sid, and a plain refresh of that token afterwards is back on the original organization.

Verification

At 3b3b983 (main merged, SUPPORTED.md regenerated):

bun run gen:events && bun run gen:shapes && bun run gen:supported && git diff --exit-code -- src/workos/generated SUPPORTED.md
bun run lint
bun run fmt:check
bun run typecheck
bun test        # 1427 pass, 0 fail across 77 files

Exchanging a refresh token deleted it, so concurrent refreshes of the
same token (a hot reload firing several requests at once) all failed
after the first with invalid_grant and signed the user out locally,
where the hosted API lets them converge.

The public session-resilience docs describe a 30-second replay grace
period: within it, replaying a just-exchanged token returns the same
rotated tokens as the first exchange; after it, the old token is
terminally invalid and a replay returns invalid_grant. The emulator now
retires an exchanged token instead of deleting it, records which token
the exchange minted, and answers a replay inside the window with that
same refresh token (walking the chain so a client several rotations
behind converges too) plus a fresh access token for the same session.
Past the window, or once the session is revoked, the retired token is
swept and the replay fails as before.
A retired token was only swept when it was replayed, but a client that
kept up never sends it again, so every normal rotation left a row behind
for good: a dev server refreshing every few minutes accumulated one per
refresh per session. A rotation now also deletes the session's retired
tokens whose grace window has closed. Chain walks are unaffected, since
anything older in a chain than a swept token was exchanged earlier and
is past its own window already.
@greptile-apps

greptile-apps Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Critical impact] The PR appears safe to merge; the previous expired-token issue is fixed and no new blocking issues were found.

Summary

Adds refresh-token replay so concurrent requests converge on the same rotated token and session.

  • Refresh-token retries return the same live token during the replay window.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Present refresh token] --> B{Session exists?}
  B -- No --> C[Delete session token chain and reject]
  B -- Yes --> D{Presented token expired?}
  D -- Yes --> E[Delete presented token and reject]
  D -- No --> F{Already exchanged?}
  F -- No --> G[Rotate within the same session]
  F -- Yes --> H{Chain still replayable?}
  H -- No --> I[Reject replay]
  H -- Yes --> J[Check live token expiry]
  J --> K[Return live refresh token and fresh access token]
Loading

Reviews (4) · Last reviewed commit: "fix(auth): Refuse an expired refresh tok..." · Reviewed by Greptile

Comment thread src/workos/routes/auth.ts
Comment thread src/workos/routes/auth.ts Outdated
Comment thread src/workos/routes/auth.spec.ts Outdated
…tent

A replay inside the grace period that also carried an organization_id
minted the access token and reported the requested organization, but
handed back the live token still bound to the previous one, so the next
plain refresh silently switched the session back. The live token now
moves to the organization the response reports.

Failing a refresh for a revoked session swept only the presented token
and the live one it resolved to, so with A -> B -> C a replay of A left
B behind with no rotation ever able to sweep it. Every token the
session still has is deleted instead.

The concurrent-replay test now starts both refreshes before awaiting
either, so it exercises the overlap the issue reported rather than a
sequential replay.
Production retires an exchanged refresh token only once the token it
was exchanged for has itself been exchanged, plus the grace period, and
never retires a token whose successor was never used: that is the
client whose rotation response was lost. The emulator measured the
window from the presented token's own exchange, so it signed out
exactly the client production protects. The replay now resolves from
the successor, caps the chain walk at 50 links, and the rotation sweep
deletes a retired row only once its successor is gone or was exchanged
more than the grace period ago.

A retired replay past the window now answers "Refresh token already
exchanged." and keeps its row, and a replay that carries an
organization_id no longer persists the switch: production's replay
returns before the exchange hook that records the session's
organization.
Production's retirement delay is not a constant: it is the feature
flag userland-refresh-token-grace-period-in-milliseconds with a code
default of 0 ms. Saying the window is "measured the way production
measures it" invited a reader to look for a hard-coded 30 s in the
monorepo, not find one, and "correct" the emulator to 0. The anchor
(the successor's exchange) is production's; the length is the public
docs'.

The replayed-organization-switch comment also sat in the response
tail, run together with the retire-on-success comment, far from the
organizationId assignment it explains.
Comment thread src/workos/routes/auth.ts
A retired token whose successor is unused replays indefinitely, so
without checking the presented token's own expiry first, a token
rotated just before its 30-day lifetime ended could ride that unused
successor past it. The emulator gives every token its own lifetime
(production bounds them by the session instead), so the lifetime has
to hold for a retired token the same as for a live one.
@gjtorikian
gjtorikian merged commit 6bf2edf into main Oct 15, 2026
10 checks passed
@gjtorikian
gjtorikian deleted the fix/refresh-token-replay-grace branch October 15, 2026 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Add 30-second grace period for concurrent refresh-token requests

1 participant