JWT + Role-Based Access in Spring Security: Refresh Tokens Without the Footguns
Rotation with reuse detection, ROLE_ vs SCOPE_ mapping, and the permitAll() trap, all tested on Spring Boot 4.1.2. Replaying an old refresh token revoked 2 tokens and killed the session.

I logged in once, refreshed once, and then replayed the old refresh token, the way an attacker would with a copy lifted from a log file or a browser extension. This is what my Spring Boot app answered, pasted from the test run:
refresh with R1 -> R2, A2 -> 200
refresh with R1 again (stolen copy) -> 401 {"error":"invalid_grant","error_description":"refresh token reuse detected; revoked 2 token(s) in family"}
refresh with R2 (legit client, after reuse) -> 401 {"error":"invalid_grant","error_description":"refresh token revoked"}
A2 GET /api/me (after reuse) -> 401 Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Session revoked"
The replay did not just fail. It killed the whole login: the newer refresh token the real user was holding, and the access token minted from it. That is refresh-token rotation with reuse detection, and it is the part most "Spring Security JWT refresh token" tutorials skip. Most of them hand back the same refresh token forever, or keep it stateless so it can't be revoked at all.
The same run also showed four smaller traps that each turn into a security bug or a support ticket: the default authority mapping gives you SCOPE_read instead of ROLE_ADMIN, permitAll() on /auth/refresh doesn't stop a stale Authorization header from failing the refresh, the default decoder still accepts a token that expired 30 seconds ago, and two browser tabs refreshing at once log the user out. Everything below is from one run of the code on JDK 25.0.5 and Spring Boot 4.1.2 (Spring Security 7.1.2). The tests run against a real Tomcat on a random port, not a mock.
The short version
- Use Spring's built-in resource-server support (
oauth2ResourceServer().jwt()) to check tokens andNimbusJwtEncoderto issue them. You don't need a hand-writtenOncePerRequestFilteror a JWT library of your own. - Access token: a short-lived JWT (5 min here) carrying
rolesand a session idsid. Refresh token: a random opaque string. Store only its SHA-256 hash, and let each one work exactly once. - On refresh, claim the token with one atomic
UPDATE ... WHERE used_at IS NULL. If a token that was already used comes back, revoke its whole family. Either the user or the attacker is holding a copy, and you can't tell which. - Map the
rolesclaim toROLE_authorities withJwtGrantedAuthoritiesConverter. By default Spring readsscope/scpand prefixesSCOPE_, sohasRole('ADMIN')never matches. - Put
/auth/**in its own filter chain without the bearer-token filter. Pin the algorithm, tighten clock skew, and checkiss. - Logout and reuse should end the session's access tokens too, not just its refresh tokens. A JWT can't be recalled on its own, so add a
sidcheck (or accept that access tokens live untilexp, and keepexpshort).
What the top results cover, and what they skip
I read the top results for "spring security jwt refresh token role based access" on 10 Nov 2026:
| Result | Good at | Missing |
|---|---|---|
| SpringJavaLab: JWT Authentication with Refresh Token, Spring Boot 3 + MySQL | Complete register/login/refresh flow with hasRole rules and a clear step table |
Refresh tokens are stateless and not stored ("tokens are not stored in the database"), so there's no rotation, no revocation and no reuse detection. Uses a custom JWT filter. |
| BezKoder: Spring Boot Refresh Token with JWT | Database-backed refresh token with an expiry check. Widely copied. | /refreshtoken returns the same refresh token (no rotation). The token is stored as-is, not hashed. No reuse detection. |
| Baeldung: OAuth 2.0 Resource Server With Spring Security | The official resource-server setup (issuer-uri, jwk-set-uri) |
Assumes an external authorization server (Keycloak). Nothing on issuing or refreshing tokens, or on role mapping pitfalls. |
| ThomasVitale/spring-security-examples: resource-server-jwt-authorities | Exactly the right roles to ROLE_ converter, plus slice and integration tests |
Only covers validation. No refresh tokens or revocation. |
| Mnourkh01/Starter-Project (GitHub) | Rotation with family revocation on replay, plus token versioning and rate limiting | A starter repo. The README lists features but doesn't show the failure modes running, or the concurrent-refresh logout. |
So the gap is not "what is a refresh token". It's a small app built on Spring's own JWT support, with rotation, reuse detection and RBAC, and a test run that shows each 401 and 403 and each default that bites. That's this post.
Setup
| What | Value |
|---|---|
| JDK | Temurin OpenJDK 25.0.5 (code compiles with --release 21) |
| Framework | Spring Boot 4.1.2, Spring Security 7.1.2, Spring MVC 7.0.10 on Tomcat 11.0.27 |
| JWT | spring-boot-starter-security-oauth2-resource-server (Nimbus JOSE + JWT 10.9.1), HS256, 256-bit key |
| Store | H2 2.4.240 in PostgreSQL mode, JdbcTemplate. The SQL is plain and portable. |
| Tests | JUnit 6.0.3, @SpringBootTest(webEnvironment = RANDOM_PORT), java.net.http.HttpClient |
| Users | alice (USER), admin (USER, ADMIN) |
| Run it | ./run.sh in the project folder (16 tests, about 6 s) |
The token lifecycle at a glance
| Access token | Refresh token | |
|---|---|---|
| Format | Signed JWT (HS256): iss, sub, iat, exp, jti, roles, sid |
32 random bytes, base64url. Opaque, not a JWT. |
| Lifetime | 5 minutes | 14 days, and one use |
| Sent to | Every API call: Authorization: Bearer ... |
Only POST /auth/refresh and /auth/logout, in the JSON body |
| Server stores | Nothing (only revoked sids) |
SHA-256(token), family_id, used_at, revoked_at, expires_at |
| Checked by | BearerTokenAuthenticationFilter → NimbusJwtDecoder + validators |
RefreshTokenService.rotate(): one atomic UPDATE |
| Dies when | exp passes, or its sid (family) is revoked |
Used once, or its family is revoked (logout or reuse), or expires_at passes |
| If stolen | Usable until exp or revocation |
Usable once. When the next use comes from the other holder, the whole family is revoked. |
Making the refresh token opaque matters more than it looks. Many tutorials make it a JWT too, signed with the same key, and then the resource server happily accepts it as an access token on every endpoint. Here a refresh token sent as a Bearer token gets Malformed token, and an access token sent to /auth/refresh gets unknown refresh token. Both are in the run below.
The schema
CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash CHAR(64) PRIMARY KEY, -- SHA-256 of the opaque token; the token itself is never stored
family_id VARCHAR(36) NOT NULL, -- one family per login; every rotation stays in it
username VARCHAR(64) NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP, -- set when rotated; a second use = reuse
revoked_at TIMESTAMP -- set on logout or reuse detection
);
CREATE INDEX IF NOT EXISTS refresh_tokens_family ON refresh_tokens(family_id);
A plain SHA-256 is enough here, and it isn't the mistake it would be for passwords. The token is 256 bits of SecureRandom, so nobody can guess or brute-force it, and you need an exact lookup by hash, which BCrypt's random salt would rule out.
Security config: Spring's JWT support, tightened
The whole thing is one class. The four parts that matter are the decoder, the authority converter, and the two filter chains:
@Bean
JwtEncoder jwtEncoder(SecretKey key) {
return new NimbusJwtEncoder(new ImmutableSecret<>(key));
}
@Bean
JwtDecoder jwtDecoder(SecretKey key,
@Value("${app.jwt.issuer}") String issuer,
@Value("${app.jwt.clock-skew}") Duration skew, // 10s
RevokedSessions revoked) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key)
.macAlgorithm(MacAlgorithm.HS256) // pin the algorithm; "none" and RS/ES tokens are rejected
.build();
// Replaces the default validator (which allows 60 s of clock skew and does not check iss).
OAuth2TokenValidator<Jwt> sessionStillActive = jwt -> revoked.isRevoked(jwt.getClaimAsString("sid"))
? OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Session revoked", null))
: OAuth2TokenValidatorResult.success();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(skew),
new JwtIssuerValidator(issuer),
sessionStillActive));
return decoder;
}
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
// Default: reads "scope"/"scp" and prefixes SCOPE_. hasRole('ADMIN') looks for ROLE_ADMIN.
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles"); // claim holds ["USER","ADMIN"], no prefix
roles.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roles);
return converter;
}
/** /auth/** has no bearer-token filter at all, so a stale Authorization header can't break refresh. */
// In the repo this bean also has @ConditionalOnProperty("app.split-auth-chain") so the pitfall test can turn it off.
@Bean
@Order(1)
SecurityFilterChain authChain(HttpSecurity http) throws Exception {
http.securityMatcher("/auth/**")
.csrf(c -> c.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a.anyRequest().permitAll());
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain apiChain(HttpSecurity http, JwtAuthenticationConverter converter) throws Exception {
http.csrf(c -> c.disable()) // no cookies: tokens travel in the Authorization header / JSON body
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(converter)));
return http.build();
}
The class also has @EnableMethodSecurity, so @PreAuthorize("hasRole('ADMIN')") on DELETE /api/users/{id} is enforced. I protect one admin route with a URL rule and one with method security so the run shows both give the same 403.
Disabling CSRF is correct only because nothing here rides on cookies. If you move the refresh token into an HttpOnly cookie (a reasonable choice for browser apps), /auth/refresh becomes CSRF-able again and you need SameSite cookies plus a CSRF token.
Issuing the access token
/** Roles come from the user store at issue time ("ROLE_ADMIN" -> "ADMIN"), never from an old token. */
public String accessToken(UserDetails user, String sessionId) {
List<String> roles = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(a -> a.startsWith("ROLE_") ? a.substring(5) : a)
.toList();
Instant now = Instant.now();
return mint(user.getUsername(), roles, sessionId, now, now.plus(accessTtl));
}
public String mint(String subject, List<String> roles, String sessionId, Instant issuedAt, Instant expiresAt) {
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(issuer)
.subject(subject)
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.id(UUID.randomUUID().toString())
.claim("roles", roles)
.claim("sid", sessionId)
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
On refresh, the controller reloads the user and mints roles from the user store again. If an admin is demoted, the next refresh drops ADMIN. Copying roles from the old token into the new one would let a demoted admin keep the role for as long as they keep refreshing.
Rotation with reuse detection
This is the core of the post. One UPDATE decides who gets to use a token. Everything after it is just working out why someone didn't:
@Transactional
public Rotation rotate(String presented) {
String hash = sha256(presented);
Timestamp now = Timestamp.from(Instant.now());
// Single atomic claim: only one caller can flip used_at from NULL for a given token.
int claimed = jdbc.update("""
UPDATE refresh_tokens SET used_at = ?
WHERE token_hash = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ?""",
now, hash, now);
Row row = find(hash);
if (claimed == 1) {
return new Rotated(row.username(), row.familyId(), issue(row.username(), row.familyId()));
}
if (row == null) return new Rejected("unknown refresh token");
if (row.revokedAt() != null) return new Rejected("refresh token revoked");
if (row.usedAt() != null) {
// An already-rotated token came back: someone else has a copy. Kill the whole family,
// including the newest token the legitimate client is holding.
int n = revokeFamily(row.familyId());
return new Rejected("refresh token reuse detected; revoked " + n + " token(s) in family");
}
return new Rejected("refresh token expired");
}
private int revokeFamily(String familyId) {
revokedSessions.revoke(familyId); // access tokens with sid=familyId now fail validation
return jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE family_id = ? AND revoked_at IS NULL",
Timestamp.from(Instant.now()), familyId);
}
Three details are easy to get wrong:
- Don't do
SELECTand thenUPDATE. Two requests could both readused_at IS NULLand both rotate, which gives you two live branches of one family. The conditionalUPDATElets the database pick exactly one winner. It's the same idea as the unique constraint in my idempotency-keys post. - Reject without throwing. The reuse branch writes (it revokes the family) and then returns a 401. If you throw a runtime exception from inside
@Transactionalinstead, Spring rolls back the revocation, and the attacker can simply try again. - Revoke the family, not just the replayed token. The server can't tell whether the replay came from the attacker or the user. Killing the family forces both to log in again, and only the real user can do that.
RevokedSessions is an in-memory Set of revoked family ids. The decoder checks it for every access token's sid. That's fine for one instance. With several instances, back it with Redis, or give up instant access-token revocation and rely on a short exp.
The tests
Every line in the run is printed by a JUnit test that also asserts the status. A typical one:
@Test @Order(20)
void rotation_thenReuse_revokesFamily() {
var login = http.login("alice", "alice-pw");
String r1 = login.json("refresh_token");
var rot = http.refresh(r1);
String r2 = rot.json("refresh_token");
String a2 = rot.json("access_token");
assertThat(rot.status()).isEqualTo(200);
var reuse = http.refresh(r1); // replay the rotated token
assertThat(reuse.status()).isEqualTo(401);
assertThat(reuse.body()).contains("reuse detected");
assertThat(http.refresh(r2).status()).isEqualTo(401); // the family is gone
assertThat(http.call("GET", "/api/me", a2, null).status()).isEqualTo(401);
}
The tampered-token test decodes alice's real access token, changes "roles":["USER"] to "roles":["USER","ADMIN"] and keeps the original signature. The expired-token test mints a correctly signed token whose exp was 30 seconds ago.
The run, unedited
./run.sh on 10 Nov 2026 (the test logger trims error_uri and resource_metadata from WWW-Authenticate for width; the first case prints one raw header so you can see them):
# ./run.sh — 10 Nov 2026 10:01 IST, Linux x86_64
== openjdk version "25.0.5" 2026-10-20 LTS; Apache Maven 3.9.11 (3e54c93a704957b63ee3494413a2b544fd3d825b); Spring Boot 4.1.2
[401] authentication failures
no Authorization header GET /api/me -> 401 Bearer
raw header: WWW-Authenticate: Bearer resource_metadata="http://localhost:35671/.well-known/oauth-protected-resource"
Bearer not-a-jwt GET /api/me -> 401 Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token"
alice's token, roles edited to ADMIN GET /api/admin/report -> 401 Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature"
alg=none token claiming ADMIN GET /api/admin/report -> 401 Bearer error="invalid_token", error_description="Unsupported algorithm of none"
validly signed, expired 30 s ago GET /api/me -> 401 Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-11-10T04:31:23Z"
[403] role checks (URL rule and @PreAuthorize)
alice GET /api/me -> 200 {"user":"alice","authorities":["FACTOR_BEARER","ROLE_USER"]}
alice GET /api/admin/report (requestMatchers) -> 403 Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token."
alice DELETE /api/users/42 (@PreAuthorize) -> 403 Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token."
admin GET /api/me -> 200 {"user":"admin","authorities":["FACTOR_BEARER","ROLE_ADMIN","ROLE_USER"]}
admin GET /api/admin/report -> 200 {"report":"ok","requestedBy":"admin"}
admin DELETE /api/users/42 -> 204
[refresh] rotation and reuse detection
login alice -> R1, A1 -> 200
refresh with R1 -> R2, A2 -> 200
R2 differs from R1 true
A2 GET /api/me -> 200 {"user":"alice","authorities":["FACTOR_BEARER","ROLE_USER"]}
refresh with R1 again (stolen copy) -> 401 {"error":"invalid_grant","error_description":"refresh token reuse detected; revoked 2 token(s) in family"}
refresh with R2 (legit client, after reuse) -> 401 {"error":"invalid_grant","error_description":"refresh token revoked"}
A2 GET /api/me (after reuse) -> 401 Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Session revoked"
A1 GET /api/me (after reuse) -> 401 Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Session revoked"
[refresh] logout
logout with R -> 204
refresh with R after logout -> 401 {"error":"invalid_grant","error_description":"refresh token revoked"}
A GET /api/me after logout -> 401 Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Session revoked"
[refresh] token confusion
access token sent to /auth/refresh -> 401 {"error":"invalid_grant","error_description":"unknown refresh token"}
refresh token sent as Bearer GET /api/me -> 401 Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token"
refresh + expired Bearer header (split chain) -> 200
[refresh] two tabs refresh the same token at once
concurrent refresh with the same R -> 200
concurrent refresh with the same R -> 401 {"error":"invalid_grant","error_description":"refresh token reuse detected; revoked 2 token(s) in family"}
statuses [200, 401]
[storage]
rows matching the raw refresh token 0
rows matching SHA-256(refresh token) 1
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.409 s -- in com.gowtham.jwt.TokenFlowTest
[pitfall] one filter chain, /auth/refresh is permitAll()
refresh + expired Bearer header (single chain) -> 401 Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-11-10T04:26:54Z"
same refresh token, no Authorization header -> 200
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.468 s -- in com.gowtham.jwt.SingleChainPitfallTest
[pitfall] default clock skew
default decoder, token expired 30 s ago accepted, exp=2026-11-10T04:31:24Z
skew 10 s, same token rejected: An error occurred while attempting to decode the Jwt: Jwt expired at 2026-11-10T04:31:24Z
[pitfall] JwtGrantedAuthoritiesConverter mapping
default converter, roles=[USER,ADMIN] scope="read write" [SCOPE_read, SCOPE_write]
claim=roles prefix=ROLE_, roles=[USER,ADMIN] [ROLE_ADMIN, ROLE_USER]
claim=roles prefix=ROLE_, roles=[ROLE_USER,ROLE_ADMIN] [ROLE_ROLE_ADMIN, ROLE_ROLE_USER]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.005 s -- in com.gowtham.jwt.AuthoritiesAndSkewPitfallTest
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
16 tests, 0 failures. What each block shows:
- 401. No token, a garbage token, a forged-role token, an
alg=nonetoken and an expired token all get401. Each one carries a preciseerror_descriptionin theWWW-Authenticateheader. Spring Security 7 also addsresource_metadata(the RFC 9728 protected-resource metadata URL) to that header. - 403. alice gets
insufficient_scopefrom both the URL rule and@PreAuthorize. Note the authorities in/api/me: Spring Security 7'sJwtAuthenticationConverteraddsFACTOR_BEARER(the multi-factor "how did you authenticate" marker) next to your roles. If you serialise or compare authority lists anywhere, expect it. - Rotation, reuse, logout. The rotated token works once. Replaying it revoked 2 tokens (R1 and R2), and both access tokens from that login now fail with
Session revoked. Logout does the same thing on purpose. - Two tabs. Two simultaneous refreshes with the same token gave
[200, 401], and the 401 revoked the family, including the token the 200 just issued. So the user is logged out by their own browser. I come back to this below. - Storage. The raw refresh token matches 0 rows. Its SHA-256 matches 1.
Pitfalls, each mapped to a line in the run
| Pitfall | What happened in the run | Fix in this code |
|---|---|---|
hasRole('ADMIN') never matches |
Default converter on roles=[USER,ADMIN] scope="read write" gave [SCOPE_read, SCOPE_write] |
JwtGrantedAuthoritiesConverter with setAuthoritiesClaimName("roles") and setAuthorityPrefix("ROLE_") |
ROLE_ prefix applied twice |
Claim already holding ROLE_ADMIN became ROLE_ROLE_ADMIN |
Store bare role names in the token (ADMIN) and strip ROLE_ when minting |
permitAll() doesn't skip the bearer filter |
Single chain: refresh with an expired Authorization header → 401 ... Jwt expired at. Same refresh token without the header → 200. |
Separate @Order(1) chain for /auth/** with no oauth2ResourceServer(). In the run: refresh + expired Bearer header (split chain) -> 200 |
| Expired tokens still accepted | Default decoder accepted a token expired 30 s ago (default skew is 60 s) | JwtTimestampValidator(Duration.ofSeconds(10)) → Jwt expired at ... |
Forged claims / alg=none |
Invalid signature and Unsupported algorithm of none |
Built-in Nimbus decoder pinned to HS256. Never parse claims yourself before verifying. |
| Refresh tokens that never die | BezKoder-style "same refresh token back" means one leak lasts the token's lifetime | Rotate on every use, store hashes, revoke the family on reuse |
| Logout leaves access tokens alive | With the sid check: A GET /api/me after logout -> 401 ... Session revoked |
sid claim + revoked-session validator, or keep access TTL short and accept the window |
| Token confusion | Access token to /auth/refresh → unknown refresh token. Refresh token as Bearer → Malformed token. |
Opaque refresh tokens, so the two types can't be swapped |
| Two tabs log the user out | Concurrent refresh → [200, 401], family revoked |
Client side: one refresh in flight per app (share the promise across tabs). Server side: an optional grace window (below). |
One more footgun, and I hit it myself on the first run: DELETE /api/users/42 returned 500, not 403, for both users. I import Spring Boot's BOM instead of using spring-boot-starter-parent, so the compiler wasn't run with -parameters, and Spring MVC couldn't resolve @PathVariable long id. Argument resolution happens before the @PreAuthorize check, so the unauthorized user got a 500 instead of a 403. The fix is <parameters>true</parameters> in maven-compiler-plugin (or @PathVariable("id")). Your RBAC tests are worth having for this alone.
The two-tab problem, honestly
Strict reuse detection can't tell "attacker replayed an old token" apart from "the same browser sent the same token twice within 50 ms". The common mitigations:
- Fix it at the client. Keep one refresh in flight and let every caller await that same promise. Across tabs, share it through a
BroadcastChannelor a lock. This is the cleanest fix. It's the same shape as the retry discipline in my Node fetch retry post. - A short grace window on the server. If a token is reused within N seconds of its rotation, return the successor instead of revoking. That needs the successor stored (encrypted) next to the used row, and it gives an attacker the same N seconds. I didn't implement it here, so I have no numbers for it.
Whichever you choose, keep the test. It's the one that tells you a release changed the behaviour.
Checklist
- Validate tokens with
oauth2ResourceServer().jwt(), not a hand-rolled filter. Issue them withNimbusJwtEncoder. - Pin the algorithm (
macAlgorithm(HS256)or an RSA key), use a 256-bit-plus secret, and checkiss(andaudif more than one API trusts the issuer). - Set clock skew deliberately. The default 60 s means "expired" tokens work for another minute.
- Map your roles claim with
JwtGrantedAuthoritiesConverterand add exactly oneROLE_prefix. - Use short access-token TTLs (minutes). Mint roles from the user store on every refresh.
- Make refresh tokens opaque and random. Store only the hash.
- Rotate on every refresh with one atomic conditional
UPDATE. - On reuse, revoke the whole family and commit that revocation even though you return 401.
- Revoke the family on logout, and decide how access tokens die (a
sidcheck, or a short TTL). - Keep
/auth/**in its own filter chain so staleAuthorizationheaders can't break refresh. - Compile with
-parametersif you don't use the Boot parent POM. - Keep tests for 401 (missing, garbage, tampered,
none, expired), 403 per role rule, rotation, reuse, logout, token confusion and concurrent refresh.
Related posts
- Idempotency Keys in Spring Boot: Making POST Safe to Retry, the same "let the database pick one winner" trick, used for retried POSTs.
- Retrying fetch in Node.js the Right Way: Backoff, Retry-After, Deadlines, the client half: one refresh in flight, with deadlines.
- Replacing ThreadLocal with ScopedValue for Request IDs (Java 25), for putting a request id next to the
subandsidin your auth logs. - Spring Boot Virtual Threads and HikariCP: Why Your Pool Runs Dry, worth reading before you add a database lookup to every authenticated request.
- Building Production-Ready Spring Boot APIs: Strategies for Top Performance
Sources
- Spring Security reference: OAuth 2.0 Resource Server JWT (decoder, validators,
JwtGrantedAuthoritiesConverter) - Spring Security reference: Multi-Factor Authentication (
FactorGrantedAuthority, whereFACTOR_BEARERcomes from) - RFC 9700: Best Current Practice for OAuth 2.0 Security (Section 4.14: refresh token rotation and replay detection)
- RFC 6750: Bearer Token Usage (
invalid_token,insufficient_scope,WWW-Authenticate) - RFC 9728: OAuth 2.0 Protected Resource Metadata (the
resource_metadataparameter)
Written by Gowtham M. Code tested on OpenJDK 25.0.5 / Spring Boot 4.1.2 / Spring Security 7.1.2, 10 Nov 2026.
Preparing for a Java backend interview? My eBook Crack the Java Backend Interview has tested answers with runnable code for questions like this one. Get it for ₹99.
Comments
Join the discussionNo comments yet. Be the first to comment.
Backend & APIs
Practical, tested backend engineering: Spring Boot, REST APIs, databases and the bugs that only show up under real traffic.




