Versions this was built and run against. Spring Boot 4.1.2 (4.1.0 shipped on June 10, 2026;4.2.0-RC1and4.2.0-RC2are release candidates, not GA), Spring Security 7.1.2, Nimbus JOSE+JWT 10.9.1, Jackson 3.1.7 (tools.jackson), H2 2.4.240, Temurin JDK 25.0.5+8. Versions were read from the resolved Maven dependency tree andmaven-metadata.xml, not from release blogs. Every console block below is copied from a file in the companion module, and the module’s tests assert the same numbers, so a number in this post stops being true only if a test starts failing. There is nodocs/folder: everything deeper than the main line sits in the expandable sections on this page.
A five-minute access token is only half of a login
An access token is a signed JWT that every API call carries. Nobody asks a database whether it is still good: the resource server checks the signature and theexp claim and moves on. That is what makes it cheap, and also why you cannot take it back. So you keep it short, five minutes here, and accept that a stolen one is useful for five minutes.
A short access token needs something to renew it, or the user logs in every five minutes. That something is the refresh token. It travels to exactly one endpoint, and the server does look it up, because this is the one place where we are willing to pay a database read to keep control.
Why not just make the access token live for a week?
Because then “logout” and “this account was compromised” have no answer. A signed JWT keeps working untilexp whatever you do on the server. Keeping the access token short and putting the long life in a revocable, server-side token moves the question “is this login still allowed?” to the one endpoint where asking is affordable.
Going deeper
- The stateless access-token side of this story is in the Spring Security 7.1 JWT Authentication guide; this post replaces its refresh endpoint with a stronger one.
- RFC 9700, OAuth 2.0 Security Best Current Practice is the current best-practice document; its refresh-token guidance is the reason rotation exists.
The refresh token is not a JWT, it is 256 random bits the server looks up
It is tempting to make the refresh token a JWT too, since the signing code already exists. Resist it. We need two properties a JWT does not give us on its own: the token must be single use and it must be revocable. Both mean “ask the database”, and once you ask the database the signature adds nothing. So the refresh token is 32 bytes fromSecureRandom, base64url encoded, and the table stores only its SHA-256.
private Issued insertNew(String username, String familyId, long familyStartedAt, long now) {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
long expiresAt = now + tokenTtl.toMillis();
store.insert(new Row(UUID.randomUUID().toString(), hash(raw), familyId, username,
RefreshTokenStore.ACTIVE, now, expiresAt, familyStartedAt, null));
return new Issued(raw, familyId, username, Instant.ofEpochMilli(expiresAt));
}
That method is in RefreshTokenService.java. The raw token goes back to the client and is never written down; the row keeps the hash, the owner, a status of ACTIVE, USED or REVOKED, and two timestamps used later for expiry. The schema is one table, in schema.sql.
raw token handed to the client: 43 characters (32 random bytes, base64url)
column token_hash holds: 64 hex characters (SHA-256)
rows matching the raw token: 0
rows matching its SHA-256: 1
The block above is output/02-storage.txt. Looking the token up by its own text finds nothing, looking it up by its SHA-256 finds the row. If someone dumps your database, they get hashes they cannot turn back into tokens.
Why plain SHA-256 and not bcrypt?
Password hashes are slow on purpose, because a password has maybe 30 bits of guessable structure and an attacker needs the slowness to make guessing expensive. This token has 256 bits of randomness and no structure to guess, so there is nothing for slowness to protect, and a slow hash would only add latency to every refresh. Hash with SHA-256 and move on.Going deeper
- The whole service, including expiry and revocation: RefreshTokenService.java.
- Persisting the family id alongside each token is what makes the next section possible; it is the
family_idcolumn in the schema file above.
Rotation: using a refresh token spends it and gives you the next one
Rotation is a single rule: a refresh token can be exchanged once. The exchange marks it USED and issues a child token in the same family, which is the shared id of everything that descends from one login. The access token the client receives carries that family id in asid claim, so the two token kinds can be tied back together later.
public Issued rotate(String rawToken) {
Row row = store.findByHash(hash(rawToken))
.orElseThrow(() -> new InvalidGrantException(Reason.UNKNOWN));
long now = clock.millis();
if (RefreshTokenStore.REVOKED.equals(row.status())) {
throw new InvalidGrantException(Reason.REVOKED);
}
if (now >= row.expiresAt() || now >= row.familyStartedAt() + familyMaxAge.toMillis()) {
throw new InvalidGrantException(Reason.EXPIRED);
}
if (RefreshTokenStore.USED.equals(row.status())) {
throw reuse(row, now);
}
// ACTIVE. Spend it and mint the child in ONE transaction, so a crash between the two
// cannot leave the user holding a spent token with no successor.
Issued issued = tx.execute(status -> {
if (!store.markUsed(row.id(), now)) {
return null; // lost a race against another request
}
return insertNew(row.username(), row.familyId(), row.familyStartedAt(), now);
});
if (issued != null) {
return issued;
}
// Somebody else spent it between our read and our UPDATE. Re-read to see how.
Row fresh = store.findByHash(hash(rawToken)).orElseThrow(() -> new InvalidGrantException(Reason.UNKNOWN));
if (RefreshTokenStore.REVOKED.equals(fresh.status())) {
throw new InvalidGrantException(Reason.REVOKED);
}
throw reuse(fresh, now);
}
The method is in RefreshTokenService.java. The order of the checks matters: unknown, revoked, expired, and only then the split between ACTIVE and USED where reuse detection lives. Here is what a login and two refreshes leave behind.
login as alice
access token: JWT, sid=22badf84
access lifetime: 300 seconds
refresh token: 43 characters, dots in it: 0
after login, refresh(A) -> B, refresh(B) -> C, one family:
token A hash 7a05668b status USED
token B hash 7bb7862a status USED
token C hash a661d1ea status ACTIVE
sid of the access token minted from C: 22badf84
family id in the table: 22badf84
That is output/01-rotation.txt. Note the last two lines: the sid inside the access token is the family id from the table, which is how a resource server can later ask whether a login is still alive. The hash prefixes change on every run because the tokens are random.
Spend and mint in one transaction. Insiderotatethe status change and the insert of the child run in oneTransactionTemplate. Without it, a crash between the two leaves the user holding a USED token and no successor, and the next request looks exactly like an attack. It is a four-line fix that is easy to leave out because the happy path works without it.
How long should each refresh token live, and does rotation extend a login forever?
Each token has its own lifetime (demo.refresh.token-ttl, 7 days in the module), so rotation behaves as an idle timeout: use the app at least once a week and you stay logged in. Left alone, that means a login never ends. The module therefore also stores when the family started and enforces demo.refresh.family-max-age (30 days), covered in the lifetimes section below. RFC 9700 asks that refresh tokens expire after a period of inactivity; the absolute cap is our addition, not something the RFC requires.
Going deeper
- Where rotation and the checks live: RefreshTokenService.java.
- The RFC’s reasoning for rotation, and the alternative of sender-constrained tokens: section 4.14 of RFC 9700.
Reuse detection: when a spent token comes back, the whole family dies
Rotation alone only makes a stolen token short-lived. Reuse detection is what makes the theft visible. Think about what a replay means: a token was exchanged once, so exactly one party got its child, and now someone presents the original again. There are two holders of the same secret. The server has no way to know which one is the thief, so it assumes the worst and revokes every token of the family.alice logs in and gets refresh token A.
An attacker copies A out of a log file.
alice's app refreshes: A -> B (A is now USED).
the attacker replays A: HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
the family after the replay:
token A hash 7131712c status REVOKED
token B hash 1c626483 status REVOKED
alice's app now presents B: HTTP 401 (the innocent holder is signed out too)
B's access token on /api/me, no sid check: HTTP 200 (still inside its 5 minutes)
That is output/03-reuse-detection.txt. The last line is the one to read twice: the access token minted from B still works on /api/me. We come back to that gap in the logout section.
The behaviour is the one section 4.14 of RFC 9700 describes: with rotation the server cannot tell whether the stale token came from the attacker or the legitimate client, so it revokes the active token, which stops the attack and forces the legitimate client to obtain a new grant. Note the RFC is descriptive here rather than prescribing the revocation step, so what you do on replay is your decision, and this module makes the strict choice.
Revoking a family does not touch the user’s other logins:
alice logs in on a phone and on a laptop: two families.
phone family 1c2bb6d5, laptop family 8c9ca716
replay of the phone's spent token: HTTP 401
laptop refresh afterwards: HTTP 200
From output/04-families-are-independent.txt. Each login is its own family, so a phone being compromised does not sign out the laptop.
What does the server log when this happens?
The service logs a warning with the user and the family id the moment it revokes, for examplerefresh token REUSE detected for user alice: family … revoked (2 tokens) (the exact format is in reuse() in RefreshTokenService.java). That line is your alerting hook: one reuse event is a stolen token or a buggy client, a burst of them for one user is worth a look. The HTTP response says nothing about why it failed; see the section on identical refusals.
Going deeper
- The decision code is
reuse()and the revoking SQL isrevokeFamilyin RefreshTokenStore.java. - section 4.14 of RFC 9700 for the standard’s wording and for sender-constrained tokens as the alternative.
Eight browser tabs, one token: the race that looks exactly like theft
Strict reuse detection has a nasty failure mode that never shows up in a manual test. Open your app in several tabs, let the access token expire, and focus the window: every tab notices at the same moment and sends the same refresh token. Only one of those requests can be first. The others present a token that was just spent, and to the server that is indistinguishable from a replay. public boolean markUsed(String id, long nowMillis) {
return jdbc.sql("update refresh_token set status = 'USED', used_at = ? where id = ? and status = 'ACTIVE'")
.params(nowMillis, id).update() == 1;
}
public int revokeFamily(String familyId) {
return jdbc.sql("update refresh_token set status = 'REVOKED' where family_id = ? and status <> 'REVOKED'")
.param(familyId).update();
}
Both are in RefreshTokenStore.java. To see it, the module fires eight requests with the same token from eight threads, released at the same instant.
8 requests, same refresh token, released together, reuse-grace = 0
HTTP 200: 1
HTTP 401: 7
family afterwards:
hash 4659f797 status REVOKED
hash 180503f6 status REVOKED
That is output/05-concurrent-refresh-strict.txt: exactly one 200, seven 401, and then the family is revoked, including the winner’s brand new token. The test asserts exactly one winner and it held on every run while writing this. The cost is the point of the section: a perfectly honest client that fired its refresh twice has just logged itself out.
The usual softening is a grace window: a token replayed within a few seconds of being spent is refused but does not revoke the family. The same stampede with demo.refresh.reuse-grace=5s:
8 requests, same refresh token, released together, reuse-grace = 5s
HTTP 200: 1
HTTP 401: 7
family afterwards:
hash 07e179bf status USED
hash f2fd3302 status ACTIVE
6 seconds later, replaying the spent token again:
HTTP 401, family now:
hash 07e179bf status REVOKED
hash f2fd3302 status REVOKED
From output/06-concurrent-refresh-grace.txt. Still one winner and seven refusals, but now the winner’s token is ACTIVE and the family lives. And six seconds later, the same replay is treated as theft and kills the family. The code that makes that decision:
private InvalidGrantException reuse(Row row, long now) {
if (row.usedAt() != null && reuseGrace.toMillis() > 0 && now - row.usedAt() <= reuseGrace.toMillis()) {
log.info("refresh token replayed {} ms after use, inside the {} ms grace window, family {} kept",
now - row.usedAt(), reuseGrace.toMillis(), row.familyId());
return new InvalidGrantException(Reason.REUSE_WITHIN_GRACE);
}
int revoked = store.revokeFamily(row.familyId());
log.warn("refresh token REUSE detected for user {}: family {} revoked ({} tokens)",
row.username(), row.familyId(), revoked);
return new InvalidGrantException(Reason.REUSE_DETECTED);
}
This grace window protects the family, not the losing request. The seven refused requests get a 401 and no token, so a client that lost the response to its one successful refresh (a flaky mobile connection) is still stranded and has to log in. Auth0’s documented overlap period behaves differently: inside the window it issues a new token instead of refusing. That requires remembering what you handed out, which this module does not do. Pick the grace behaviour on purpose rather than by default.
The better fix is on the client: one refresh at a time
The cleanest way to avoid the race is to never send two refreshes for the same token. A client keeps a single in-flight refresh promise; every caller that needs a new access token while one is pending awaits the same promise instead of sending its own. Across browser tabs this takes a cross-tab lock. This is advice from experience of the failure mode rather than something the companion repository demonstrates, so treat it as a design suggestion and test it in your client.Going deeper
- The full strict and grace tests:
ConcurrentRefreshStrictTestandConcurrentRefreshGraceTestinsrc/test/java/com/ankurm/refresh/of the module. - Auth0: configure refresh token rotation and the overlap period for how a commercial provider describes its overlap window and the previous-token-only rule.
Logout works on the refresh token and not on the access token
Because every login is a family and every family is a set of rows, logging out is a revoke. One device revokes the family of the token it presents. Logging out everywhere revokes every row for the user, and that endpoint takes an access token, since “all my sessions” needs an identity. public Map<String, Object> logoutAll(@AuthenticationPrincipal Jwt jwt) {
return ordered("revoked_tokens", refreshTokens.revokeAll(jwt.getSubject()));
}
That is from AuthController.java. The single-device endpoint always answers 204, even for a token that never existed, so it cannot be used to test whether a token is real. Here is a whole session with three devices for alice and one for bob.
alice is logged in on phone, laptop and tablet (3 families); bob on one device.
POST /api/auth/logout with the phone's refresh token: HTTP 204
phone refresh: HTTP 401
laptop refresh: HTTP 200
POST /api/auth/logout with a made-up token: HTTP 204 (same answer, no oracle)
POST /api/auth/logout-all with the laptop's access token: HTTP 200 {"revoked_tokens":3}
laptop refresh: HTTP 401
tablet refresh: HTTP 401
bob refresh: HTTP 200 (untouched)
alice's laptop ACCESS token on /api/me after logout-all: HTTP 200
(it is a signed JWT with 5 minutes left; nothing in the request path asked the store)
The block is output/07-logout.txt. Refreshing after logout-all fails for every alice device and bob is untouched. The last two lines are the honest part. Logging out everywhere did not stop alice’s access token, because it is a signed JWT with minutes to run and nothing on the request path asks the database.
demo.check-sid=true a validator rejects any access token whose sid family has no live tokens left:
public OAuth2TokenValidatorResult validate(Jwt jwt) {
String sid = jwt.getClaimAsString("sid");
if (sid != null && store.familyAlive(sid)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "The session behind this token has ended", null));
}
Same flow, with that switched on:
alice logs in. /api/me with the access token: HTTP 200
after logout-all, same access token, demo.check-sid=true: HTTP 401
That is the first two lines of output/08-check-sid.txt (the third, the full WWW-Authenticate header, is cut for width). The token dies with its family.
The sid check turns your stateless API into a stateful one. The validator runs a database query on every authenticated request, which is exactly the lookup the access JWT existed to avoid. Turn it on when immediate revocation matters more than the extra read (an admin console, say), and leave it off for the high-volume endpoints. Caching the result for a few seconds is a middle path; this module does not implement it.
Going deeper
- The validator and its switch: SidValidator.java.
- How a resource server validates tokens issued elsewhere, including key rotation: the resource server and key rotation post.
Two lifetimes, one hard cap, and a minute nobody asked for
Three clocks are running. The access token has five minutes. Each refresh token has seven days, which acts as an idle timeout because every rotation starts a fresh seven days. And the family has an absolute age cap of 30 days, so rotation cannot stretch one login forever. All three are properties:demo:
jwt:
issuer: https://refresh-demo.ankurm.com
access-ttl: 5m
# 32+ bytes. Demo value only - a real deployment reads this from a secret manager.
hmac-secret: ankurm-refresh-demo-hmac-secret-32-bytes-min
refresh:
# Lifetime of ONE refresh token. Each rotation hands out a fresh one, so this is an idle timeout.
token-ttl: 7d
# Absolute cap on a whole token family. Rotation cannot extend a login past this.
family-max-age: 30d
# 0 = strict: any replay of a spent token revokes the family. >0 = tolerate a replay this soon after use.
reuse-grace: 0s
# true adds a validator that rejects an access token whose family (sid claim) was revoked.
check-sid: false
From application.yaml. To test time without waiting, the tests move an injected Clock. Here is the idle timeout and then the cap, which is the surprising one: a token six days old is refused because its family is thirty days old.
token-ttl = 7d, family-max-age = 30d, access-ttl = 5m
5m01s after login, access token on /api/me: HTTP 401
8 days idle, refresh: HTTP 401 (token-ttl passed)
day 6: refresh OK (every token is only 6 days old)
day 12: refresh OK (every token is only 6 days old)
day 18: refresh OK (every token is only 6 days old)
day 24: refresh OK (every token is only 6 days old)
day 30: refresh HTTP 401, the token is 6 days old but the family is 30 days old
family rows: 5, all in the same family 721853e3
That is output/09-expiry.txt. Five tokens in one family, every one of them young, and the next refresh is still refused.
The “minute nobody asked for” is in the access token side. Spring Security’s default timestamp validator allows 60 seconds of clock skew, so a five-minute token is accepted for six. The module’s decoder sets the skew to zero:
public JwtDecoder jwtDecoder(Clock clock, ObjectProvider<SidValidator> sidValidator) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build();
JwtTimestampValidator timestamps = new JwtTimestampValidator(Duration.ZERO);
timestamps.setClock(clock);
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
validators.add(timestamps);
validators.add(new JwtIssuerValidator(issuer));
sidValidator.ifAvailable(validators::add);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
return decoder;
}
And the comparison, with a token that expired 30 seconds ago:
an access token that expired 30 seconds ago:
decoder with Spring Security's default validators accepts it: true
this module's decoder (clock skew set to zero) accepts it: false
From output/10-clock-skew.txt. The 60 seconds is not folklore: javap on spring-security-oauth2-jose 7.1.2 shows DEFAULT_MAX_CLOCK_SKEW initialised to 60 seconds in its static initialiser. Zero skew is a choice, safe only while the issuer and the verifier share a reliable clock, so if your resource servers are separate machines, keep a few seconds.
Going deeper
- The config and the injected clock: JwtConfig.java and
MutableClockin the module’s test sources. - How the decoder is wired and why the algorithm is pinned: the Spring Security 7.1 JWT Authentication guide.
Refuse every bad refresh the same way, and watch it over real HTTP
There are four ways a refresh can fail: the token never existed, it was already used, it was revoked, or it expired. If those produce four different responses, an attacker holding a guessed or half-stolen token learns which kind it was. The controller maps them all to one status and one body, theinvalid_grant error that RFC 6749 section 5.2, the invalid_grant error defines for a bad grant.
/** Map.of() iterates in a different order on every JVM start; a stable body keeps reruns diffable. */
private static Map<String, String> error(String description) {
Map<String, String> body = new LinkedHashMap<>();
body.put("error", "invalid_grant");
body.put("error_description", description);
return body;
}
The reason for each failure goes to the log and to the tests, never to the caller.
never issued HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
already used (reuse) HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
revoked by logout HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
expired HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
From output/11-identical-refusals.txt: four reasons, four identical lines. Last, the same story over a real HTTP server rather than MockMvc, with curl against the packaged jar.
$ curl -X POST /api/auth/login (alice)
{"access_token":"eyJraWQiOiJMeX...","refresh_token":"FeHO52BY0danWV...","token_type":"Bearer","expires_in":300}
$ curl /api/me (Bearer access token)
{"sub":"alice","sid":"c9ddb06a..."}
HTTP 200
$ curl -X POST /api/auth/refresh (token A)
{"access_token":"eyJraWQiOiJMeX...","refresh_token":"zgB_rx1Kaqu_AZ...","token_type":"Bearer","expires_in":300}
$ curl -X POST /api/auth/refresh (token A again: the replay)
{"error":"invalid_grant","error_description":"The refresh token is not valid"}
HTTP 401
$ curl -X POST /api/auth/refresh (token B, the innocent holder)
{"error":"invalid_grant","error_description":"The refresh token is not valid"}
HTTP 401
That is output/12-http-walkthrough.txt, produced by http-walkthrough.sh, with long tokens shortened for the page only. Login, an authenticated call, a rotation, then the replay and the innocent holder both refused.
Three small things that cost a build or a diff while writing this
- On Boot 4,
@Validand@NotBlankneedspring-boot-starter-validationas an explicit dependency. The first compile failed withcannot find symbol: class Valid. - The first version of the error body used
Map.of(...), whose iteration order changes from one JVM start to the next. The committed transcripts reordered the JSON keys on every rerun, so the controller now builds aLinkedHashMap. - The tests parse responses with Jackson 3 (
tools.jackson), the version Boot 4.1 manages. The annotation@JsonPropertyis still imported fromcom.fasterxml.jackson.annotation, and the module compiled with that import.
Going deeper
- Everything above runs with
./scripts/run-all.shin the module; the tests are insrc/test/java/com/ankurm/refresh/.
Should you build this yourself at all?
Probably not, if you already run an authorization server. Spring Authorization Server, Keycloak and the commercial providers issue refresh tokens for you and, depending on the product and its settings, rotate them; they are maintained by people whose full-time job is this. In this repository’s own captured output (as-settings-defaults.txt)The short version of the design is five rules. The refresh token is random and stored as a hash. It is spent on use. Tokens of one login share a family. A spent token coming back revokes the family. And you decide, deliberately, what happens when two honest requests collide.TokenSettings.isReuseRefreshTokens()istrueby default in both Spring Authorization Server 1.5.8 and 7.1.1, which means refresh tokens are not rotated unless you switch it off. I did not test whether Spring Authorization Server revokes a whole family on replay, so I am not claiming it does. Read your provider’s behaviour for replayed tokens and for concurrent refreshes before you rely on it. Build your own when you issue tokens from the same app that serves the API and an OAuth server would be more moving parts than the problem deserves.
Two limits of this module. The data lives in H2 in memory, so everything is lost on restart; a real deployment uses your production database and cleans up old rows. And a browser app still has to store the refresh token somewhere. An HTTP-only cookie limited to the refresh path keeps it away from page scripts, and that matters more than any rotation scheme, but it is a deployment decision this repository does not exercise.
Further reading
- The companion module,
refresh-tokensin spring-auth-demo, with all twelve transcripts inoutput/. - The Spring Security 7.1 JWT Authentication guide, which this post extends.
- The captured Spring Authorization Server defaults and the Spring Authorization Server post, if you would rather use a real provider.
- The resource server and key rotation post for the other side of the wire: how a separate service validates what you issue.
- RFC 9700, OAuth 2.0 Security Best Current Practice, in particular section 4.14 of RFC 9700.
- RFC 6749 section 5.2, the invalid_grant error.
- Auth0: configure refresh token rotation and the overlap period.
No Comments yet!