Session resilience
Understand refresh token rotation, the replay grace period, and how to handle transient failures without signing users out.
AuthKit keeps a session alive by exchanging its refresh token for a new access token whenever the access token expires, and the SDKs handle this for you. When a refresh fails, one distinction determines whether the session survives: a transient failure should be retried and the session kept, while a terminal failure means the user must sign in again.
This page covers refresh token rotation, the replay grace period, and how to tell transient and terminal failures apart so a momentary problem never forces an unnecessary sign-out.
When you exchange a refresh token using the authenticate with refresh token endpoint, AuthKit returns a new access token and a new refresh token, and the refresh token you sent is retired. This rotation limits the useful lifetime of any single refresh token.
Because the token rotates on every use, always persist the newly returned refresh token and discard the old one. If you store the refresh token in a database or cache, write the new value before serving the next request. The WorkOS SDKs that manage sealed sessions do this for you.
Rotation creates a race: if the same refresh token is exchanged twice at nearly the same moment – for example, two concurrent requests both notice an expired access token, or a client retries after a slow network – the second exchange would otherwise fail because the token was already rotated.
To make this safe, 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.
Two consequences worth designing around:
- Retrying a refresh with the same token inside the grace period is safe and idempotent – you get the same new tokens back.
- Once the grace period elapses, the old token is terminally invalid. A replay after that point returns an
invalid_granterror and the session must re-authenticate.
The most important distinction when handling a failed refresh is whether the failure is terminal or transient.
Terminal failures mean the session is genuinely over and the user must sign in again. These are returned as an authentication error with the OAuth invalid_grant code at HTTP 400 – for example, a revoked, expired, or already-consumed (past the grace period) refresh token.
Transient failures mean the request didn’t complete but the session is still valid. Retrying the same refresh token is the correct response. These include:
- Network errors and request timeouts (for example, a dropped connection or a
The read operation timed outerror). 5xxresponses from a temporary server-side or upstream issue.429 Too Many Requests, which AuthKit returns for short-lived refresh contention. Back off briefly and retry the same refresh token.
Use this table as the decision rule when a refresh completes:
| Response | Classification | What to do |
|---|---|---|
200 OK with new tokens |
Success | Persist the rotated refresh token and continue |
400 with OAuth invalid_grant |
Terminal | Clear the session and redirect to sign-in |
408 Request Timeout or a network/connection error |
Transient | Keep the session; retry with backoff |
429 Too Many Requests |
Transient | Keep the session; back off and retry |
500, 502, 503, 504 |
Transient | Keep the session; retry with backoff |
| Any other unexpected error | Treat as terminal | Clear the session and redirect to sign-in |
Never destroy a session on a transient failure. Clear session state and
redirect to sign-in only for a terminal invalid_grant. Treating a timeout
or 5xx as terminal turns a brief blip into an unnecessary sign-out.
How much of this you handle yourself depends on which SDK you use. The framework integrations that manage the session cookie for you are turn-key – they refresh automatically, keep the session on a transient failure, and redirect to sign-in only on a terminal invalid_grant. The backend SDKs hand you a typed result and never clear the session on your behalf, so you decide what to do with each outcome.
| SDK | Refresh & session handling | What you write |
|---|---|---|
authkit-nextjs, authkit-remix, authkit-react-router, authkit-sveltekit, authkit-astro, authkit-tanstack-start |
Turn-key. The middleware or loader refreshes automatically, keeps the session cookie on a transient failure, and redirects to sign-in only on a terminal invalid_grant. |
Nothing – resilient handling is built in. Optionally hook onSessionRefreshError to customize. |
authkit-js (browser) |
Turn-key. Refreshes in the background, preserves stored session state on a transient failure, and clears it only on a terminal failure. | Nothing required. Optionally handle the onRefreshFailure callback. |
workos-node, workos-python, workos-ruby, workos-go, workos-ios (Swift) |
Bring-your-own handling. refresh() (or its equivalent) returns a typed result and never clears the session for you. Transient transport errors are retried internally, then surfaced. |
Inspect the result: redirect to sign-in on a terminal outcome; keep the session and retry on a transient one. |
Keeping the session on a transient failure was added in recent versions of
authkit-nextjs, authkit-remix, authkit-react-router, and authkit-js.
Earlier versions signed the user out on any refresh failure, so upgrade to the
latest to get the turn-key behavior described above.
Under the hood, the turn-key integrations are built on these backend SDKs, so the same rules apply – they just apply the terminal-vs-transient decision for you. For example, the Node SDK’s HTTP client automatically retries idempotent failures – request timeouts (normalized to 408), 429, and 500/502/503/504 – with exponential backoff before surfacing an error, so a brief refresh contention or upstream blip is retried transparently rather than bubbling up as a failed refresh.
When you handle the result yourself, the rule is the same one the integrations follow: redirect to sign-in on a terminal outcome (such as invalid_grant), and reserve clearing the session cookie for that case. If a transient failure surfaces after the SDK’s internal retries, keep the existing session in place and try again on the next request rather than signing the user out.
Turn-key framework (authkit-nextjs). The middleware refreshes the session, keeps the cookie on a transient failure, and redirects to sign-in only on a terminal invalid_grant. You don’t write any refresh logic; the callback below is optional and only for observability.
Backend SDK (workos-node). refresh() returns a typed result and never clears the session for you. The Node SDK additionally retries transient transport failures (timeouts, 429, 5xx) internally before returning, and reports anything still transient with retryable: true.
Backend SDK where you own the retry (workos-go). Most language SDKs give you the same typed result but do not retry for you, so you add the bounded retry and backoff yourself. Refresh reports a terminal failure as refresh_token_revoked and anything else (429, 5xx, network) as refresh_failed.
If you call the authenticate with refresh token endpoint directly, replicate what the SDK does: on a transient failure (a timeout, 5xx, or 429), retry the same refresh token with a bounded number of attempts and exponential backoff, keep serving the current request with the existing (still-valid) session while a retry is pending, and only give up – and re-authenticate – on a terminal invalid_grant or once retries are exhausted.
Applying the transient-versus-terminal distinction is what keeps your app usable when a dependency degrades and refreshes start failing intermittently:
- Keep the existing session and access token in place until the access token actually expires.
- Retry failed refreshes with backoff instead of treating the first failure as a sign-out.
- Redirect to sign-in only on a terminal
invalid_grant.
This keeps a service disruption from cascading into signing out users who are already authenticated.
Grace-period behavior is separate from the session lifetimes you control in the dashboard. For maximum session length, access token duration, and inactivity timeout, see Configuring sessions.