Okta Refresh Token Rotation in SPAs: Worth It, If You Handle the Race
Okta's refresh token rotation with reuse detection shrinks the window a stolen SPA token is useful—but concurrent tabs can trigger false compromise signals. Here's how to configure it, verify it, and handle the race.
09 Jul 2025, 05:55 UTC

Single-page applications have an awkward security problem: they are public clients, so they can't hold a secret, yet users expect to stay signed in for days. The traditional answer—long-lived access tokens stored in the browser—means a stolen token is useful for its entire lifetime. Okta's refresh token rotation with reuse detection is the better answer, and this post explains what it actually buys you, how to wire it up, and the one race condition that will log your users out if you ignore it.
The thesis: rotate the refresh token, not just the access token
With rotation enabled, every time your SPA exchanges a refresh token at Okta's token endpoint, the response contains a new refresh token and the old one is invalidated. The companion behavior, reuse detection, is what makes this powerful: if an already-rotated token is ever presented again, Okta treats it as a compromise signal and revokes the whole token chain, forcing re-authentication. An attacker who exfiltrates a refresh token gets a shrinking window of usefulness—and using it likely burns the attacker's own copy.
Configuration lives in two places
Two settings must agree, and missing either is a common setup failure:
- App integration: the refresh token grant must be explicitly allowed on your SPA application. Enabling PKCE does not imply refresh tokens.
- Authorization server access policy: this is where you set the refresh token lifetime and rotation behavior. Lifetime and rotation interact—a rotated token that inherits an absolute lifetime cap behaves differently from one whose lifetime slides—so review both together.
Exact option names and defaults vary by Okta org configuration and have changed over time, so confirm against your own org's admin console rather than trusting any blog's screenshots, including this one.
A worked example: React SPA with the Okta-hosted widget
A typical setup: a React app using @okta/okta-auth-js (often via @okta/okta-react), redirecting to the Okta-hosted sign-in page with PKCE. On the Auth JS side you request the scopes that permit refresh tokens and let the SDK manage storage and renewal:
import { OktaAuth } from '@okta/okta-auth-js';
const oktaAuth = new OktaAuth({
issuer: 'https://<your-okta-domain>/oauth2/default',
clientId: '<spa-client-id>',
redirectUri: window.location.origin + '/login/callback',
scopes: ['openid', 'profile', 'offline_access'],
pkce: true,
tokenManager: { autoRenew: true }
});The offline_access scope is what requests a refresh token; without it, rotation is moot. With autoRenew enabled, the SDK silently exchanges the refresh token before access-token expiry. Verify the setup in a free developer org: watch network traffic to the token endpoint and confirm each refresh response carries a different refresh token than the one sent. Then deliberately replay the previous refresh token (e.g., with curl against the token endpoint) and confirm the chain is revoked and the next renewal attempt fails, forcing sign-in. That replay test is the proof reuse detection is actually on.
The trade-off: concurrent tabs and the reuse trap
Reuse detection can't distinguish an attacker from your own app racing itself. If two browser tabs both attempt a silent renewal with the same refresh token, the second request presents an already-rotated token—and Okta revokes the chain. Result: a perfectly legitimate user is abruptly logged out. This is the most common complaint after enabling rotation, and it's a real engineering cost, not a theoretical one.
Mitigations, in rough order of effort:
- SDK version and storage strategy: current Okta SDKs coordinate renewal through shared storage (localStorage) so tabs share tokens rather than each holding their own. Confirm your SDK version's behavior—older versions and custom storage implementations are where races sneak in.
- Renewal locking: if you manage tokens yourself, serialize refreshes with a cross-tab lock (e.g., a Web Lock or a localStorage-based mutex) so only one tab refreshes at a time.
- Graceful retry: treat a reuse-detection failure as "re-read tokens from shared storage once" before concluding the session is dead; often another tab already refreshed successfully.
The other limitation: rotation doesn't help if the attacker steals the current token and uses it before your app does. It shrinks the window; it doesn't close it. Pair it with a refresh token lifetime you can justify, not the maximum allowed.
What to do next
If your SPA currently relies on long-lived access tokens or silent renewal via hidden iframes with session cookies, rotation plus reuse detection is a concrete upgrade: short-lived access tokens, no third-party-cookie fragility, and automatic compromise detection. Enable the refresh token grant on a test app integration, turn on rotation in the access policy, run the replay test to prove detection works, and then hammer it with two tabs before rolling out. The logout race is manageable—but only if you test for it before your users find it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.