# JWT + Role-Based Access in Spring Security: Refresh Tokens Without the Footguns

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:

```text
  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 and `NimbusJwtEncoder` to *issue* them. You don't need a hand-written `OncePerRequestFilter` or a JWT library of your own.
- Access token: a short-lived JWT (5 min here) carrying `roles` and a session id `sid`. 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 `roles` claim to `ROLE_` authorities with `JwtGrantedAuthoritiesConverter`. By default Spring reads `scope`/`scp` and prefixes `SCOPE_`, so `hasRole('ADMIN')` never matches.
- Put `/auth/**` in its own filter chain without the bearer-token filter. Pin the algorithm, tighten clock skew, and check `iss`.
- 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 `sid` check (or accept that access tokens live until `exp`, and keep `exp` short).

![Refresh-token rotation with reuse detection: login, rotate, replay of an old token revokes the family](https://cdn.hashnode.com/uploads/covers/658c3a46e339366b1a80b9f3/034dfdda-2bf1-45df-800b-7f90c693cfe2.png align="center")

## 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](https://www.springjavalab.com/2025/11/spring-boot-jwt-authentication-refresh-token-role-based-access-complete-guide.html) | 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](https://www.bezkoder.com/spring-boot-refresh-token-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](https://www.baeldung.com/spring-security-oauth-resource-server) | 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](https://github.com/ThomasVitale/spring-security-examples/tree/main/oauth2/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)](https://github.com/Mnourkh01/Starter-Project) | 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 `sid`s) | `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

```sql
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:

```java
@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

```java
/** 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:

```java
@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:

1. **Don't do** `SELECT` **and then** `UPDATE`**.** Two requests could both read `used_at IS NULL` and both rotate, which gives you two live branches of one family. The conditional `UPDATE` lets the database pick exactly one winner. It's the same idea as the unique constraint in my [idempotency-keys post](https://gowthammuthuvel.hashnode.dev/idempotency-keys-spring-boot).
2. **Reject without throwing.** The reuse branch *writes* (it revokes the family) and *then* returns a 401. If you throw a runtime exception from inside `@Transactional` instead, Spring rolls back the revocation, and the attacker can simply try again.
3. **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:

```java
@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):

```text
# ./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=none` token and an expired token all get `401`. Each one carries a precise `error_description` in the `WWW-Authenticate` header. Spring Security 7 also adds `resource_metadata` (the RFC 9728 protected-resource metadata URL) to that header.
- **403.** alice gets `insufficient_scope` from both the URL rule and `@PreAuthorize`. Note the authorities in `/api/me`: Spring Security 7's `JwtAuthenticationConverter` adds `FACTOR_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 `BroadcastChannel` or a lock. This is the cleanest fix. It's the same shape as the retry discipline in my [Node fetch retry post](https://gowthammuthuvel.hashnode.dev/node-fetch-retry-backoff-retry-after-deadline).
- **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 with `NimbusJwtEncoder`.
- [ ] Pin the algorithm (`macAlgorithm(HS256)` or an RSA key), use a 256-bit-plus secret, and check `iss` (and `aud` if 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 `JwtGrantedAuthoritiesConverter` and add exactly one `ROLE_` 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 `sid` check, or a short TTL).
- [ ] Keep `/auth/**` in its own filter chain so stale `Authorization` headers can't break refresh.
- [ ] Compile with `-parameters` if 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](https://gowthammuthuvel.hashnode.dev/idempotency-keys-spring-boot), 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](https://gowthammuthuvel.hashnode.dev/node-fetch-retry-backoff-retry-after-deadline), the client half: one refresh in flight, with deadlines.
- [Replacing ThreadLocal with ScopedValue for Request IDs (Java 25)](https://gowthammuthuvel.hashnode.dev/replace-threadlocal-with-scopedvalue-java-25), for putting a request id next to the `sub` and `sid` in your auth logs.
- [Spring Boot Virtual Threads and HikariCP: Why Your Pool Runs Dry](https://gowthammuthuvel.hashnode.dev/spring-boot-virtual-threads-hikaricp-pool-exhausted), worth reading before you add a database lookup to every authenticated request.
- [Building Production-Ready Spring Boot APIs: Strategies for Top Performance](https://gowthammuthuvel.hashnode.dev/building-production-ready-spring-boot-apis-strategies-for-top-performance)

## Sources

- [Spring Security reference: OAuth 2.0 Resource Server JWT](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html) (decoder, validators, `JwtGrantedAuthoritiesConverter`)
- [Spring Security reference: Multi-Factor Authentication](https://docs.spring.io/spring-security/reference/servlet/authentication/mfa.html) (`FactorGrantedAuthority`, where `FACTOR_BEARER` comes from)
- [RFC 9700: Best Current Practice for OAuth 2.0 Security](https://www.rfc-editor.org/rfc/rfc9700) (Section 4.14: refresh token rotation and replay detection)
- [RFC 6750: Bearer Token Usage](https://www.rfc-editor.org/rfc/rfc6750) (`invalid_token`, `insufficient_scope`, `WWW-Authenticate`)
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728) (the `resource_metadata` parameter)

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](https://topmate.io/gowthammuthuvel/2404119).
