Mid 8 min · May 23, 2026

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.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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)
✦ Definition~90s read
What is Spring Boot JWT Refresh Token Flow?

The JWT refresh token pattern separates authentication concerns between two token types. The access token is a short-lived, signed JWT containing the user's identity and permissions. It is validated locally by verifying the cryptographic signature — no database call required. Its short TTL (15 minutes) limits the window of compromise if stolen.

Think of access tokens as daily parking passes and refresh tokens as the annual membership card.

The refresh token is a long-lived credential (7 days) used only to obtain new access tokens. It is never sent to API endpoints — only to the dedicated /auth/refresh endpoint. Unlike access tokens, refresh tokens are stateful: each refresh token has a unique jti (JWT ID) stored server-side, enabling instant revocation.

On every use, the old refresh token is invalidated and a new one is issued (rotation).

Token family tracking groups all refresh tokens issued from a single login event into a family. Each rotation links the new token to the family. If any revoked token in the family is presented (indicating theft and replay), the entire family is invalidated — all active refresh tokens from that login session are cancelled, forcing re-authentication.

Plain-English First

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.

Use RS256 not HS256 in microservices
HS256 requires every verifying service to hold the secret key — a shared secret that becomes a single point of compromise. RS256 lets services verify using the public key only; only the auth service holds the private key.
Production Insight
Generate access tokens without any PII (email, name) — put the user ID in sub and let downstream services look up profile data. This minimizes data exposure if tokens are logged or intercepted.
Key Takeaway
Access tokens (15 min, slim claims) and refresh tokens (7 days, with JTI + familyId) serve different roles — never mix their claims or use them interchangeably.

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.

Use Lua scripts for atomic Redis operations
GET + conditional SET + new SET across three Redis commands is not atomic. A crash between steps leaves both tokens valid (security hole) or neither valid (UX failure). Use a Lua script or Redis transactions to guarantee atomicity.
Production Insight
Set a short TTL (5 minutes) on used refresh tokens rather than deleting them immediately — this handles the race condition where two simultaneous refresh requests present the same token.
Key Takeaway
Atomic rotation (revoke old + store new in one Redis transaction/Lua script) is non-negotiable — a non-atomic rotation creates a window where both the old and new token are simultaneously valid.

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 cookies on OAuth2 redirects
If you use OAuth2 authorization code flow, the redirect back to your app is a cross-site navigation — SameSite=Strict blocks the cookie. Use SameSite=Lax for OAuth2 flows and rely on CSRF tokens or origin validation for cross-site protection.
Production Insight
Set 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.
Key Takeaway
HttpOnly + Secure + SameSite=Strict + Path=/auth/refresh is the complete cookie security configuration — omitting any attribute weakens the protection against XSS, CSRF, and network interception.

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.

Deduplicate concurrent refresh calls
Multiple simultaneous 401 responses trigger multiple refresh calls. Each uses the same refresh token but the second call receives a used/rotated token and triggers theft detection. Deduplicate with a shared Promise reference — if a refresh is in progress, queue callers to await the same promise.
Production Insight
Dispatch a 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.
Key Takeaway
Store access tokens in memory only, use a proactive refresh timer triggered 2 minutes before expiry, and deduplicate concurrent refresh calls with a shared Promise to prevent race conditions.

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."

Publish a security event on theft detection
When a token family is invalidated, publish a 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.
Production Insight
Keep compromised family IDs in Redis for 24 hours after invalidation — if the attacker attempts to use another token from the same family, you can immediately identify it as part of a known compromise rather than a fresh detection.
Key Takeaway
Token family tracking converts theft from an invisible ongoing attack into a detectable, measurable event — when a used token is replayed, the entire family is invalidated and a security alert is triggered.

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.

Rate limit /auth/refresh per user ID not just per IP
Rate limiting by IP only can be bypassed with rotating proxy IPs. Rate limit by user ID extracted from the refresh token — a single user should call /auth/refresh at most once per access token TTL (15 minutes). Block users exceeding 10 refreshes per hour.
Production Insight
Add a 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.
Key Takeaway
Protect /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.
● Production incidentPOST-MORTEMseverity: high

Refresh Token Theft Undetected for 6 Days Due to Missing Rotation

Symptom
User account shows login activity from two geographic locations simultaneously. Session invalidation (password change) had no effect because the attacker held a valid refresh token that was not rotated or tracked.
Assumption
The team assumed that storing refresh tokens in localStorage was acceptable because the app performed XSS sanitization. Token rotation was not implemented because it adds complexity.
Root cause
Refresh tokens were long-lived (7 days), stored in localStorage (accessible to any JavaScript on the page), never rotated, and not tracked server-side. A stored XSS payload in a user-generated content field executed once and exfiltrated the refresh token. The attacker silently refreshed access tokens for 6 days.
Fix
Migrated refresh tokens to HttpOnly Secure SameSite=Strict cookies (inaccessible to JavaScript). Implemented server-side refresh token tracking in Redis with JTI-based revocation. Added rotation on every use. Added token family tracking to detect and respond to replay attacks. Changed password now correctly invalidates all refresh tokens by deleting all JTIs for that user.
Key lesson
  • 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.
Production debug guideSymptom → root cause → fix5 entries
Symptom · 01
Users are logged out every 15 minutes (silent refresh not working)
Fix
Check that the SPA is intercepting 401 responses and calling /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.
Symptom · 02
Refresh endpoint returns 401 despite a recently issued refresh token
Fix
Check Redis connectivity — if Redis is down, all token lookups fail. Verify the JTI key format: the stored key should match 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.
Symptom · 03
Token family invalidation is triggering for legitimate users
Fix
This usually indicates a race condition where two simultaneous refresh requests both present the same refresh token. One succeeds and rotates the token; the other receives the rotated token's predecessor and triggers theft detection. Fix by adding a distributed lock (Redis SETNX) per user during refresh operations, or use a short grace period (1-2 seconds) before marking a used token as fully revoked.
Symptom · 04
HttpOnly cookie not being sent on refresh requests from SPA
Fix
Verify the cookie domain matches the API domain. If the SPA is at 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'.
Symptom · 05
Redis memory growing unbounded with refresh token keys
Fix
Verify that all Redis keys have a TTL set. Call 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.
★ Debug Cheat SheetFast commands for diagnosing refresh token and JWT issues in production.
Verify refresh token JTI exists in Redis
Immediate action
Check Redis for the token JTI
Commands
redis-cli GET refresh:<jti-value>
redis-cli TTL refresh:<jti-value>
Fix now
If missing (nil), token was expired or revoked. If TTL=-1, add expiry: redis-cli EXPIRE refresh:<jti> 604800
List all active refresh tokens for a user+
Immediate action
Scan Redis for user-scoped token keys
Commands
redis-cli SMEMBERS user:tokens:<userId>
redis-cli KEYS 'refresh:*' | head -20
Fix now
Revoke all: redis-cli DEL $(redis-cli SMEMBERS user:tokens:<userId>)
Check if access token is expired or malformed+
Immediate action
Decode JWT claims without verification
Commands
echo '<jwt>' | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
curl -s -H 'Authorization: Bearer <token>' https://api.example.com/actuator/health
Fix now
If exp is in the past, silent refresh failed — check SPA refresh logic
Force revoke all sessions for a compromised user+
Immediate action
Delete all refresh token keys for the user
Commands
redis-cli SMEMBERS user:tokens:123 | xargs redis-cli DEL
redis-cli DEL user:tokens:123
Fix now
Also invalidate any active access tokens by adding user's sub to a short-lived blocklist in Redis
Refresh Token Storage Strategies
StorageXSS RiskCSRF RiskPersistenceRecommended For
localStorageHIGH — JS readableNoneCross-tab, survives reloadNever for tokens
sessionStorageHIGH — JS readableNoneTab only, cleared on closeAccess token only, short-lived
Memory (JS var)LOW — cleared on reloadNoneTab only, lost on reloadAccess tokens in SPA
HttpOnly CookieNONE — JS cannot readNeeds SameSiteConfigurableRefresh tokens (recommended)
Service Worker cacheMEDIUM — SW is JSNoneConfigurableExperimental, complex

Key takeaways

1
Always rotate refresh tokens on every use and implement server-side JTI tracking in Redis
without these, stolen refresh tokens are undetectable.
2
Store refresh tokens in HttpOnly Secure SameSite=Strict cookies
localStorage exposes tokens to XSS attacks from any JavaScript on the page.
3
Token family tracking converts token theft from an invisible ongoing attack into a detectable, alertable event that terminates all related sessions.
4
Use atomic Redis operations (Lua script or MULTI/EXEC) for refresh token rotation
a non-atomic rotation creates a window where two tokens are simultaneously valid.
5
Proactive silent refresh (2 minutes before expiry) provides seamless UX; reactive refresh (on 401) is the fallback
implement both with a shared Promise to prevent duplicate calls.

Common mistakes to avoid

6 patterns
×

Not rotating refresh tokens on every use

Symptom
A stolen refresh token can generate access tokens for its entire TTL (7 days) without detection
Fix
Issue a new refresh token on every /auth/refresh call and immediately revoke the old one. Use a Lua script for atomic rotation in Redis.
×

Storing refresh tokens in localStorage

Symptom
XSS attack exfiltrates refresh tokens; attacker maintains long-term access
Fix
Store refresh tokens in HttpOnly Secure SameSite=Strict cookies. Store access tokens in memory only.
×

Not implementing server-side refresh token tracking

Symptom
Password change or account lock does not invalidate active refresh tokens
Fix
Store refresh token JTIs in Redis with user-level sets. On password change or forced logout, delete all JTIs for the user.
×

Issuing refresh tokens with the same expiry each time they are rotated

Symptom
A user who logs in once and keeps refreshing has an effectively permanent session
Fix
Set the new refresh token's expiry to originalLoginTime + 7 days, not now + 7 days. Store the original issuance time in the family metadata.
×

Not handling concurrent refresh requests (race condition)

Symptom
Two simultaneous requests both present the same refresh token; the second triggers theft detection and logs out a legitimate user
Fix
Set used refresh tokens to a 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

Symptom
If the refresh token verification key is compromised, access tokens can also be forged
Fix
Use separate signing keys for access and refresh tokens, or include a tokenType claim and validate it — reject refresh tokens presented to API endpoints.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why use short-lived access tokens combined with long-lived refresh token...
Q02JUNIOR
What is refresh token rotation and why is it important?
Q03SENIOR
How do you prevent a race condition where two simultaneous requests both...
Q04SENIOR
Explain token family tracking and the theft detection mechanism.
Q05JUNIOR
Why store refresh tokens in HttpOnly cookies rather than localStorage?
Q06SENIOR
How do you implement silent token refresh in a React SPA?
Q07SENIOR
What Redis data structures would you use for refresh token management an...
Q08SENIOR
How do you handle the scenario where a user's refresh token expires (7 d...
Q01 of 08JUNIOR

Why use short-lived access tokens combined with long-lived refresh tokens instead of a single long-lived token?

ANSWER
Long-lived tokens are revocable only by invalidating the signing key, affecting all users. Short-lived access tokens (15 min) limit theft exposure — stolen tokens expire quickly. Refresh tokens are stateful and revocable server-side instantly via Redis JTI deletion. This combines the performance of stateless JWT validation with the security of server-side revocability.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
How long should access tokens and refresh tokens live?
02
Can I revoke an access token before it expires?
03
Should refresh tokens themselves be JWTs or opaque random strings?
04
How do I handle refresh tokens in a mobile app?
05
What happens to all sessions when a user changes their password?
06
How do you monitor the health of the refresh token system?
🔥

That's Spring Security. Mark it forged?

8 min read · try the examples if you haven't

Previous
OAuth2 with Spring Security
3 / 4 · Spring Security
Next
Role-Based Access Control with Spring Security