Spring Boot JWT Refresh Token Flow: Rotation, Redis Revocation & Silent Refresh
Implement JWT refresh token flow in Spring Boot with short-lived access tokens, rotation on every use, Redis JTI blacklisting, HttpOnly cookies, and token family theft detection.
- Issue short-lived access tokens (15 min) and long-lived refresh tokens (7 days) at login
- Rotate refresh tokens on every use — issue a new refresh token and invalidate the old one
- Store refresh token JTI in Redis to enable instant revocation without waiting for expiry
- Deliver refresh tokens via HttpOnly Secure SameSite=Strict cookies to prevent JavaScript access
- Implement token family tracking: if a reused/revoked refresh token is presented, invalidate the entire family (theft detection)
Think of access tokens as daily parking passes and refresh tokens as the annual membership card. The parking pass expires every 15 minutes — if stolen, it is only useful briefly. The membership card lasts 7 days but lives in a locked wallet (HttpOnly cookie) that JavaScript cannot reach. When you renew your parking pass, you get a new membership card and the old one is cancelled immediately — so if someone steals your old card and tries to use it, the system detects a replay and cancels everything.
You deploy JWT authentication and set the token expiry to 24 hours for user convenience. Three months later, an employee's laptop is stolen. The thief uses the cached token to access the API for the next 22 hours despite the employee immediately changing their password. Your only option is to rotate your signing key — invalidating every active session for every user. This is the consequence of long-lived access tokens.
The access token / refresh token pattern solves this. Access tokens live for 15 minutes. They are stateless — validated purely by cryptographic signature, requiring no server state. If stolen, they are useful for at most 15 minutes. Refresh tokens are long-lived (7 days) but stateful — tracked server-side, revocable instantly, and rotated on every use.
Refresh token rotation is the critical security mechanism that most tutorials skip. Without rotation, a stolen refresh token can generate access tokens for 7 days. With rotation, the refresh token changes on every use. When the legitimate user next refreshes, their token is now invalid (the attacker used it). The server detects this as a reuse attack and can invalidate the entire token family.
Redis is the natural store for refresh token metadata. JWT IDs (JTI) are stored with a TTL matching the token expiry. Revocation is O(1) — set the JTI key as revoked in Redis. The access token validator checks the jti against Redis on each request (or on a subset of requests with local caching).
This guide covers the complete production implementation: JWT generation with jti claims, refresh token rotation, Redis-based revocation, HttpOnly cookie delivery, silent refresh in SPAs, and token family tracking for theft detection.
JWT Token Generation: Access and Refresh Tokens
The foundation of the refresh token pattern is correct JWT generation. Access tokens and refresh tokens serve different purposes and should have different claims, different TTLs, and different scopes of applicability.
Access tokens should be slim — include only what authorization logic needs: sub (user ID), roles or scope, jti (unique ID for revocation), iat (issued at), and exp (expiry 15 minutes out). Including email, full name, or preferences in the access token is tempting but adds bytes to every request header and makes the token a larger target.
Refresh tokens can be JWT or opaque random strings. JWT refresh tokens are convenient because they are self-contained and can carry the jti, sub, familyId, and exp without a lookup. However, you still need server-side tracking (Redis) for rotation and revocation, which somewhat negates the stateless benefit. Plain random strings (UUID or 256-bit random) stored in Redis are simpler and equally secure.
For the JWT signing key, use RS256 (RSA) or ES256 (ECDSA) rather than HS256 (HMAC). Asymmetric keys allow public key distribution for token verification without sharing the signing key. Services can verify tokens independently. Rotate signing keys regularly (quarterly) and publish the new key in the JWKS endpoint before rotating.
Always include jti claims on both token types. The JTI is a UUID generated per token issuance. For access tokens, the JTI enables targeted revocation (mark a specific token as revoked without rotating the signing key). For refresh tokens, the JTI is the primary revocation handle in Redis.
sub and let downstream services look up profile data. This minimizes data exposure if tokens are logged or intercepted.Refresh Token Rotation and Redis-Based Revocation
Rotation is the mechanism that makes refresh tokens safe despite their long TTL. On every refresh request, the endpoint performs these steps atomically: verify the incoming refresh token, issue a new refresh token with a new JTI, mark the old JTI as used/revoked in Redis, store the new JTI in Redis, and return a new access token alongside the new refresh token.
The atomicity is critical. Use a Redis transaction (MULTI/EXEC) or a Lua script to ensure that the old token is revoked in the same operation that stores the new one. A failure between these two steps leaves the system in an inconsistent state where both the old and new token may be valid simultaneously.
Redis schema for refresh tokens: refresh:{jti} → {userId}:{familyId}:{status} with TTL equal to the token's remaining lifetime. Status can be active, used, or revoked. A used token that is presented again triggers theft detection. Include a user-level set user:refresh-tokens:{userId} → Set<jti> for efficient bulk revocation (logout all sessions, password change).
For access token revocation (optional, for logout): maintain a short-lived Redis blocklist access:revoked:{jti} with TTL equal to the access token's remaining lifetime. Check this blocklist in the JWT filter for every request, or only for sensitive operations. The cost is one Redis lookup per request — acceptable for sensitive APIs, too expensive for high-frequency read APIs.
used refresh tokens rather than deleting them immediately — this handles the race condition where two simultaneous refresh requests present the same token.HttpOnly Secure Cookies for Refresh Token Delivery
Storing refresh tokens in localStorage or sessionStorage exposes them to any JavaScript running on the page — including third-party scripts, browser extensions, and XSS payloads. An HttpOnly cookie cannot be read or modified by JavaScript; only the browser's HTTP stack can access it, and it is automatically included in requests to the cookie's domain.
The Secure flag ensures the cookie is only sent over HTTPS connections, preventing interception on unencrypted connections. SameSite=Strict prevents the cookie from being sent in cross-site requests, mitigating CSRF attacks. SameSite=Lax is a reasonable compromise for SPAs on the same domain (allows top-level navigations but blocks cross-site AJAX).
When the SPA and API are on different subdomains (e.g., app.example.com and api.example.com), set the cookie domain to .example.com (note leading dot) to allow the cookie to be sent to all subdomains. Use SameSite=None; Secure for true cross-origin scenarios (different domains), but be aware this requires HTTPS and may be blocked by browser privacy settings.
The refresh token cookie should only be accepted at the /auth/refresh endpoint — not at every API endpoint. Restrict the cookie's Path to /auth/refresh to prevent it from being sent on every request. This reduces exposure and ensures the access token (in the Authorization header) is the primary credential for API calls.
For logout, clear the refresh token cookie server-side by setting Max-Age=0 and issue a Redis revocation for the JTI. Never rely on the client to delete cookies — a malicious actor retaining the cookie bytes can replay it if server-side revocation is absent.
SameSite=Strict blocks the cookie. Use SameSite=Lax for OAuth2 flows and rely on CSRF tokens or origin validation for cross-site protection.Path=/auth/refresh on the refresh token cookie — this prevents the refresh token from being sent on every API request, reducing the attack surface and cookie transmission overhead.Silent Refresh in Single-Page Applications
A silent refresh strategy maintains the user's session without interrupting their workflow. The SPA tracks the access token expiry and proactively calls /auth/refresh before the token expires — typically with 1-2 minutes of lead time. This avoids the jarring experience of a request failing with 401 and requiring the user to log in again.
The standard implementation uses an HTTP interceptor (Axios interceptor, Angular HttpInterceptor, or a custom fetch wrapper). The interceptor attaches the access token to every request. If a 401 is received, it calls /auth/refresh, stores the new access token in memory, and replays the failed request. Queue multiple simultaneous 401 responses to avoid multiple concurrent refresh calls.
Storing the access token in memory (a JavaScript variable, not localStorage) is the most secure approach. Memory tokens are cleared when the tab closes or the page reloads. Use sessionStorage only if you need persistence across page refreshes within a tab. Never use localStorage — it persists across tabs and browser restarts, increasing the exposure window.
For the proactive refresh timer, decode the access token's exp claim on the client side (no signature verification needed — the server verifies on each request). Set a setTimeout for (exp - now - 120) * 1000 milliseconds to trigger refresh 2 minutes before expiry. Reset the timer whenever a new token is received.
Handle the edge case where the refresh token itself has expired (7 days since last login). The /auth/refresh endpoint returns 401. The interceptor should redirect to the login page instead of retrying. Distinguish between access_token_expired (retryable) and refresh_token_expired (requires re-login) using response body error codes.
Promise reference — if a refresh is in progress, queue callers to await the same promise.storage event (or use a BroadcastChannel) when a new access token is received so other open tabs can update their token without each independently calling /auth/refresh.Token Family Tracking and Theft Detection
Token family tracking is the mechanism that converts refresh token theft from an ongoing threat into a detectable event. Every refresh token issued at login starts a new family, identified by a UUID familyId. Each rotation links the new token to the same family. When a revoked or used token is presented, the server can identify the family and invalidate all tokens in it.
The theft scenario: user logs in → receives refresh token A (family X). Attacker steals token A via XSS or network interception. User refreshes → A is rotated to B (family X). Attacker presents A → server detects A is used, identifies family X, invalidates B and any future tokens in family X. Both user and attacker are logged out. User re-authenticates; attacker cannot.
Without family tracking, there is no way to correlate a used token (A) with the currently active token (B). The server only knows A was used but cannot find B to revoke it.
Implement the family as a Redis sorted set or a simple DB table. The Redis approach: family:{familyId} → sorted set of {jti}:{status}:{timestamp}. On each rotation, add the new JTI. On theft detection, scan the set and revoke all active JTIs.
Consider the UX implication: token theft detection logs out both the legitimate user and the attacker. The legitimate user must re-authenticate. This is the correct behavior — re-authentication is the only way to establish trust after a potential compromise. Send the user a security notification email: "Your session was terminated because someone may have stolen your login token. Please change your password."
TokenTheftEvent to your event bus. Subscribe to it for: sending the user a security email, logging to a SIEM, creating a security incident ticket, and optionally temporarily locking the account pending investigation.Refresh Token Endpoint Security and CSRF Protection
The /auth/refresh endpoint is a high-value target. It is the only endpoint that can generate new access tokens, and it authenticates via cookie rather than Authorization header. This makes it vulnerable to CSRF attacks if not properly protected.
CSRF attacks work when a malicious site causes the victim's browser to make a request to your API that includes the victim's cookies. If /auth/refresh uses cookies and has no CSRF protection, an attacker's site could silently refresh tokens (though they cannot read the response due to CORS). More dangerously, if /auth/logout is also cookie-authenticated, an attacker could log out the victim.
SameSite=Strict on the refresh token cookie is the primary CSRF defense — the browser will not send the cookie on cross-site requests. For SameSite=Lax or SameSite=None, add a Double Submit Cookie pattern or Synchronizer Token Pattern at the /auth/refresh endpoint specifically.
Rate limit the /auth/refresh endpoint aggressively. A legitimate user should call it at most once every 15 minutes (once per access token expiry). Rate limit by source IP and by user ID. Implement exponential backoff for repeated failures from the same IP. Add monitoring for unusual patterns — a single IP refreshing for hundreds of different users indicates credential stuffing or token harvesting.
For the access token filter, add JTI-based revocation checking selectively. Checking Redis on every request adds latency. Strategies: check only for sensitive operations (account changes, payment endpoints); use a local Bloom filter refreshed every minute to avoid Redis calls for most requests; or accept the latency and check Redis on every request for high-security APIs.
/auth/refresh at most once per access token TTL (15 minutes). Block users exceeding 10 refreshes per hour.X-Request-ID header to refresh requests and log it alongside the JTI and user ID — when investigating a security incident, correlating refresh requests to specific sessions is invaluable./auth/refresh with SameSite=Strict cookies, per-user rate limiting, and IP-based anomaly detection — it is the most sensitive endpoint in your authentication infrastructure.Refresh Token Theft Undetected for 6 Days Due to Missing Rotation
it adds complexity.- localStorage is not a secure token store — XSS anywhere on your domain can steal it.
- HttpOnly cookies prevent JavaScript access entirely.
- Stateless refresh tokens without server-side tracking cannot be revoked.
- Always implement rotation and revocation together.
/auth/refresh before the access token expires. The silent refresh should trigger when the access token has less than 2 minutes remaining, not after expiry. Inspect the exp claim from the decoded token and verify the timer logic. Check CORS configuration — the /auth/refresh endpoint must allow credentials (Access-Control-Allow-Credentials: true) and the exact origin (not *) for cookies to be sent.refresh:<jti> exactly as looked up in the endpoint. Check Redis TTL: TTL refresh:<jti> — if it returns -2 (key not found) or -1 (no TTL), the key was either never set or had no expiry. Add a Redis health check to your application's startup sequence.app.example.com and the API at api.example.com, set cookie.setDomain(".example.com") (note the leading dot). Verify SameSite=Strict is not blocking cross-site requests — for separate SPA/API domains, use SameSite=None with Secure=true. Ensure fetch or axios is called with credentials: 'include'.TTL refresh:<jti> on a sample key — it must return a positive integer. If TTL is -1 (no expiry), the expire() call is missing in the token storage code. Also check that the revocation blacklist keys (for used/revoked JTIs) have a TTL equal to the remaining token lifetime, not the full 7 days. Monitor redis-cli info memory and add Redis memory limits with maxmemory-policy allkeys-lru.redis-cli GET refresh:<jti-value>redis-cli TTL refresh:<jti-value>redis-cli EXPIRE refresh:<jti> 604800Key takeaways
Common mistakes to avoid
6 patternsNot rotating refresh tokens on every use
/auth/refresh call and immediately revoke the old one. Use a Lua script for atomic rotation in Redis.Storing refresh tokens in localStorage
Not implementing server-side refresh token tracking
Issuing refresh tokens with the same expiry each time they are rotated
originalLoginTime + 7 days, not now + 7 days. Store the original issuance time in the family metadata.Not handling concurrent refresh requests (race condition)
used state with a 5-second grace period before triggering theft detection. Or use a distributed lock per user during the refresh operation.Using the same secret/key for both access and refresh tokens
tokenType claim and validate it — reject refresh tokens presented to API endpoints.Interview Questions on This Topic
Why use short-lived access tokens combined with long-lived refresh tokens instead of a single long-lived token?
Frequently Asked Questions
That's Spring Security. Mark it forged?
8 min read · try the examples if you haven't