JWT Authentication with Refresh Token Rotation in Spring Boot 4 and Spring Security 7

Introduction

JSON Web Tokens (JWT) have become the de-facto standard for stateless authentication in modern REST APIs. But a naive implementation — issuing one long-lived token and calling it done — is a liability. If the token leaks, it stays valid until expiry. There is no revocation.

The solution is a two-token architecture: a short-lived access token (15 minutes) paired with a longer-lived refresh token (7 days) that rotates on every use. This tutorial builds the complete system using Spring Boot 4.1 and Spring Security 7, leveraging the framework's native spring-boot-starter-oauth2-resource-server — no hand-rolled JWT parsing, no jjwt dependency, no custom filters for token validation.

We cover:

  • RS256 asymmetric key signing with NimbusJwtEncoder / NimbusJwtDecoder

  • Redis-backed refresh token storage (only the SHA-256 hash is stored)

  • HttpOnly Secure cookie delivery scoped to the refresh endpoint

  • Token family reuse detection: one compromised token invalidates the entire session

  • Patching CVE-2025-53864 (nimbus-jose-jwt DoS) via a version override

Note on versions: Spring Boot 3.x reached end-of-life in June 2026. For all new projects, target Spring Boot 4.1 (supported until July 2027) with Spring Security 7.x. Spring Boot 4.x requires Java 17+; this tutorial targets Java 21 (current LTS).

Prerequisites

  • Java 21+
  • Maven 3.9+
  • Redis (docker run -d -p 6379:6379 redis:7-alpine)
  • Familiarity with Spring Boot basics and HTTP authentication concepts

Project Setup

pom.xml

The nimbus-jose-jwt.version override patches CVE-2025-53864, a denial-of-service vulnerability triggered by deeply nested JSON in JWT claim sets:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.0</version>
    <relativePath/>
  </parent>
  <groupId>com.vimleshpandey.demo</groupId>
  <artifactId>jwt-auth-demo</artifactId>
  <version>1.0.0</version>
  <properties>
    <java.version>21</java.version>
    <nimbus-jose-jwt.version>10.0.2</nimbus-jose-jwt.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

src/main/resources/application.yml

spring:
  datasource:
    url: jdbc:h2:mem:authdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: false
  data:
    redis:
      host: localhost
      port: 6379

app:
  jwt:
    access-token-expiry-minutes: 15
    refresh-token-expiry-days: 7
    issuer: https://vimleshpandey.com
  rsa:
    private-key: classpath:certs/private.pem
    public-key: classpath:certs/public.pem

Generating RSA Keys

Store the key pair in src/main/resources/certs/. Never commit private.pem — add the directory to .gitignore and inject the key via environment variables or a secrets manager in production.

mkdir -p src/main/resources/certs

# PKCS#8 format — required by Spring Security
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
  -out src/main/resources/certs/private.pem

openssl rsa -in src/main/resources/certs/private.pem \
  -pubout -out src/main/resources/certs/public.pem

RSA Key Configuration

package com.vimleshpandey.demo.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.Resource;

@ConfigurationProperties(prefix = "app.rsa")
public record RsaKeyProperties(Resource privateKey, Resource publicKey) {}

Enable it on the main application class:

package com.vimleshpandey.demo;

import com.vimleshpandey.demo.config.RsaKeyProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

@SpringBootApplication
@EnableConfigurationProperties(RsaKeyProperties.class)
public class JwtAuthDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(JwtAuthDemoApplication.class, args);
    }
}

Security Configuration

WebSecurityConfigurerAdapter was removed in Spring Security 6.0 and remains absent in 7.x. The modern approach declares a SecurityFilterChain bean using the lambda DSL:

package com.vimleshpandey.demo.config;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;

import java.security.KeyFactory;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final RsaKeyProperties rsaKeys;

    public SecurityConfig(RsaKeyProperties rsaKeys) {
        this.rsaKeys = rsaKeys;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s ->
                s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 ->
                oauth2.jwt(jwt ->
                    jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(
                    (req, res, e) -> res.sendError(401, "Unauthorized"))
                .accessDeniedHandler(
                    (req, res, e) -> res.sendError(403, "Forbidden")))
            .build();
    }

    @Bean
    public JwtDecoder jwtDecoder() throws Exception {
        return NimbusJwtDecoder.withPublicKey(loadPublicKey()).build();
    }

    @Bean
    public JwtEncoder jwtEncoder() throws Exception {
        RSAKey rsaKey = new RSAKey.Builder(loadPublicKey())
            .privateKey(loadPrivateKey()).build();
        return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(rsaKey)));
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }

    @Bean
    public AuthenticationManager authenticationManager(
            UserDetailsService uds, PasswordEncoder pe) {
        DaoAuthenticationProvider p = new DaoAuthenticationProvider();
        p.setUserDetailsService(uds);
        p.setPasswordEncoder(pe);
        return new ProviderManager(p);
    }

    private JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter gac = new JwtGrantedAuthoritiesConverter();
        gac.setAuthorityPrefix("ROLE_");
        gac.setAuthoritiesClaimName("roles");
        JwtAuthenticationConverter c = new JwtAuthenticationConverter();
        c.setJwtGrantedAuthoritiesConverter(gac);
        return c;
    }

    private RSAPublicKey loadPublicKey() throws Exception {
        String pem = new String(rsaKeys.publicKey().getInputStream().readAllBytes())
            .replaceAll("-----[^-]+-----", "").replaceAll("\\s", "");
        return (RSAPublicKey) KeyFactory.getInstance("RSA")
            .generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(pem)));
    }

    private RSAPrivateKey loadPrivateKey() throws Exception {
        String pem = new String(rsaKeys.privateKey().getInputStream().readAllBytes())
            .replaceAll("-----[^-]+-----", "").replaceAll("\\s", "");
        return (RSAPrivateKey) KeyFactory.getInstance("RSA")
            .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(pem)));
    }
}

Key points about the config:

  • csrf().disable() + STATELESS sessions is correct for a JWT REST API — no server-side session means no CSRF surface

  • oauth2ResourceServer() wires Spring Security's built-in JWT validation pipeline — no custom OncePerRequestFilter needed

  • NimbusJwtDecoder.withPublicKey() explicitly enforces RS256; alg:none and algorithm-confusion attacks are rejected at the library level

  • @EnableMethodSecurity replaces the removed @EnableGlobalMethodSecurity

Token Service

The TokenService generates signed JWTs for access and opaque random strings for refresh. Only the SHA-256 hash of each refresh token touches Redis — a database compromise yields hashes that cannot be reversed to the original tokens.

package com.vimleshpandey.demo.service;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.stereotype.Service;

import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Service
public class TokenService {

    private static final String RT_PREFIX = "refresh_token:";
    private static final String FAMILY_PREFIX = "token_family:";

    private final JwtEncoder jwtEncoder;
    private final RedisTemplate<String, String> redisTemplate;

    @Value("${app.jwt.access-token-expiry-minutes:15}")
    private long accessTokenExpiryMinutes;

    @Value("${app.jwt.refresh-token-expiry-days:7}")
    private long refreshTokenExpiryDays;

    @Value("${app.jwt.issuer}")
    private String issuer;

    public TokenService(JwtEncoder jwtEncoder,
                        RedisTemplate<String, String> redisTemplate) {
        this.jwtEncoder = jwtEncoder;
        this.redisTemplate = redisTemplate;
    }

    public String generateAccessToken(UserDetails userDetails) {
        Instant now = Instant.now();
        List<String> roles = userDetails.getAuthorities().stream()
            .map(a -> a.getAuthority().replace("ROLE_", ""))
            .toList();
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer(issuer)
            .issuedAt(now)
            .expiresAt(now.plus(accessTokenExpiryMinutes, ChronoUnit.MINUTES))
            .subject(userDetails.getUsername())
            .claim("roles", roles)
            .build();
        JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).build();
        return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }

    public String generateRefreshToken(String username) {
        String token = secureRandom();
        String hash = sha256(token);
        String familyId = UUID.randomUUID().toString();
        storeToken(hash, username, familyId);
        addToFamily(familyId, hash);
        return token;
    }

    public RefreshResult rotateRefreshToken(String incoming) {
        String hash = sha256(incoming);
        String key = RT_PREFIX + hash;

        String revoked  = (String) redisTemplate.opsForHash().get(key, "revoked");
        String username = (String) redisTemplate.opsForHash().get(key, "username");
        String familyId = (String) redisTemplate.opsForHash().get(key, "familyId");

        if (revoked == null)
            throw new IllegalArgumentException("Refresh token not found");

        if ("true".equals(revoked)) {
            revokeFamily(familyId);
            throw new IllegalStateException(
                "Token reuse detected — all sessions invalidated");
        }

        redisTemplate.opsForHash().put(key, "revoked", "true");

        String newToken = secureRandom();
        String newHash = sha256(newToken);
        storeToken(newHash, username, familyId);
        addToFamily(familyId, newHash);
        return new RefreshResult(username, newToken);
    }

    public void revokeToken(String token) {
        redisTemplate.opsForHash().put(RT_PREFIX + sha256(token), "revoked", "true");
    }

    private void storeToken(String hash, String username, String familyId) {
        String key = RT_PREFIX + hash;
        redisTemplate.opsForHash().put(key, "username", username);
        redisTemplate.opsForHash().put(key, "familyId", familyId);
        redisTemplate.opsForHash().put(key, "revoked", "false");
        redisTemplate.expire(key, refreshTokenExpiryDays, TimeUnit.DAYS);
    }

    private void addToFamily(String familyId, String hash) {
        String familyKey = FAMILY_PREFIX + familyId;
        redisTemplate.opsForSet().add(familyKey, hash);
        redisTemplate.expire(familyKey, refreshTokenExpiryDays, TimeUnit.DAYS);
    }

    private void revokeFamily(String familyId) {
        var members = redisTemplate.opsForSet().members(FAMILY_PREFIX + familyId);
        if (members != null)
            members.forEach(h ->
                redisTemplate.opsForHash().put(RT_PREFIX + h, "revoked", "true"));
    }

    private String secureRandom() {
        byte[] b = new byte[32];
        new SecureRandom().nextBytes(b);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(b);
    }

    private String sha256(String input) {
        try {
            MessageDigest d = MessageDigest.getInstance("SHA-256");
            return Base64.getUrlEncoder().withoutPadding()
                .encodeToString(d.digest(input.getBytes()));
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public record RefreshResult(String username, String newRefreshToken) {}
}

How Token Family Reuse Detection Works

Each refresh token belongs to a token family (a UUID). When RT-1 is used to get RT-2:

  1. RT-1 is marked revoked: true in Redis
  2. RT-2 is issued into the same family

If a stolen copy of RT-1 is presented after the legitimate user already has RT-2:

  1. Redis shows revoked: true — it was already used
  2. All tokens in the family are immediately revoked — RT-2 and every subsequent token become invalid
  3. Both the attacker and the legitimate user must re-authenticate

This turns token theft into a detectable event that forces re-login, converting a silent compromise into an active alert.

User Model and Repository

package com.vimleshpandey.demo.model;

import jakarta.persistence.*;
import java.util.Set;

@Entity
@Table(name = "users")
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String username;

    @Column(nullable = false)
    private String password;

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "user_roles",
        joinColumns = @JoinColumn(name = "user_id"))
    @Column(name = "role")
    private Set<String> roles;

    public Long getId() { return id; }
    public String getUsername() { return username; }
    public void setUsername(String u) { this.username = u; }
    public String getPassword() { return password; }
    public void setPassword(String p) { this.password = p; }
    public Set<String> getRoles() { return roles; }
    public void setRoles(Set<String> r) { this.roles = r; }
}
package com.vimleshpandey.demo.repository;

import com.vimleshpandey.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}
package com.vimleshpandey.demo.service;

import com.vimleshpandey.demo.repository.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    public UserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        return userRepository.findByUsername(username)
            .map(u -> User.withUsername(u.getUsername())
                .password(u.getPassword())
                .authorities(u.getRoles().stream()
                    .map(SimpleGrantedAuthority::new).toList())
                .build())
            .orElseThrow(() ->
                new UsernameNotFoundException("User not found: " + username));
    }
}

Authentication Controller

package com.vimleshpandey.demo.controller;

import com.vimleshpandey.demo.dto.*;
import com.vimleshpandey.demo.model.User;
import com.vimleshpandey.demo.repository.UserRepository;
import com.vimleshpandey.demo.service.TokenService;
import jakarta.servlet.http.*;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.*;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.util.Arrays;
import java.util.Set;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthenticationManager authManager;
    private final TokenService tokenService;
    private final UserDetailsService userDetailsService;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public AuthController(AuthenticationManager authManager,
                          TokenService tokenService,
                          UserDetailsService userDetailsService,
                          UserRepository userRepository,
                          PasswordEncoder passwordEncoder) {
        this.authManager = authManager;
        this.tokenService = tokenService;
        this.userDetailsService = userDetailsService;
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @PostMapping("/register")
    public ResponseEntity<String> register(
            @Valid @RequestBody RegisterRequest req) {
        if (userRepository.findByUsername(req.username()).isPresent())
            return ResponseEntity.badRequest().body("Username already taken");
        User user = new User();
        user.setUsername(req.username());
        user.setPassword(passwordEncoder.encode(req.password()));
        user.setRoles(Set.of("USER"));
        userRepository.save(user);
        return ResponseEntity.ok("Registered successfully");
    }

    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(
            @Valid @RequestBody LoginRequest req,
            HttpServletResponse res) {
        var auth = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                req.username(), req.password()));
        UserDetails ud = (UserDetails) auth.getPrincipal();
        String accessToken  = tokenService.generateAccessToken(ud);
        String refreshToken = tokenService.generateRefreshToken(ud.getUsername());
        setRefreshCookie(res, refreshToken);
        return ResponseEntity.ok(new TokenResponse(accessToken, 15));
    }

    @PostMapping("/refresh")
    public ResponseEntity<TokenResponse> refresh(
            HttpServletRequest req, HttpServletResponse res) {
        String rt = extractCookie(req, "refreshToken");
        if (rt == null) return ResponseEntity.status(401).build();
        try {
            TokenService.RefreshResult result = tokenService.rotateRefreshToken(rt);
            UserDetails ud = userDetailsService.loadUserByUsername(result.username());
            setRefreshCookie(res, result.newRefreshToken());
            return ResponseEntity.ok(
                new TokenResponse(tokenService.generateAccessToken(ud), 15));
        } catch (Exception e) {
            clearRefreshCookie(res);
            return ResponseEntity.status(401).build();
        }
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(
            HttpServletRequest req, HttpServletResponse res) {
        String rt = extractCookie(req, "refreshToken");
        if (rt != null) tokenService.revokeToken(rt);
        clearRefreshCookie(res);
        return ResponseEntity.noContent().build();
    }

    private void setRefreshCookie(HttpServletResponse res, String token) {
        Cookie c = new Cookie("refreshToken", token);
        c.setHttpOnly(true);
        c.setSecure(true);
        c.setPath("/api/auth/refresh");  // scoped — only sent to this endpoint
        c.setMaxAge(7 * 24 * 60 * 60);
        res.addCookie(c);
    }

    private void clearRefreshCookie(HttpServletResponse res) {
        Cookie c = new Cookie("refreshToken", "");
        c.setHttpOnly(true);
        c.setSecure(true);
        c.setPath("/api/auth/refresh");
        c.setMaxAge(0);
        res.addCookie(c);
    }

    private String extractCookie(HttpServletRequest req, String name) {
        if (req.getCookies() == null) return null;
        return Arrays.stream(req.getCookies())
            .filter(c -> name.equals(c.getName()))
            .map(Cookie::getValue).findFirst().orElse(null);
    }
}

DTOs

// LoginRequest.java
package com.vimleshpandey.demo.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(@NotBlank String username, @NotBlank String password) {}

// RegisterRequest.java
package com.vimleshpandey.demo.dto;
import jakarta.validation.constraints.*;
public record RegisterRequest(
    @NotBlank @Size(min = 3, max = 50) String username,
    @NotBlank @Size(min = 8) String password
) {}

// TokenResponse.java
package com.vimleshpandey.demo.dto;
public record TokenResponse(String accessToken, long expiresInMinutes) {}

Protected Endpoint Example

package com.vimleshpandey.demo.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
@RequestMapping("/api")
public class UserController {

    @GetMapping("/me")
    public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
        return Map.of(
            "username", jwt.getSubject(),
            "roles",    jwt.getClaimAsStringList("roles"),
            "issuedAt", jwt.getIssuedAt().toString()
        );
    }

    @GetMapping("/admin/dashboard")
    @PreAuthorize("hasRole('ADMIN')")
    public Map<String, String> adminDashboard() {
        return Map.of("message", "Admin access confirmed");
    }
}

Testing

package com.vimleshpandey.demo;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.vimleshpandey.demo.dto.LoginRequest;
import com.vimleshpandey.demo.dto.RegisterRequest;
import com.vimleshpandey.demo.repository.UserRepository;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.*;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.issuer=https://test.example.com")
class JwtAuthIntegrationTest {

    @Autowired MockMvc mockMvc;
    @Autowired ObjectMapper objectMapper;
    @Autowired UserRepository userRepository;

    @BeforeEach
    void setUp() { userRepository.deleteAll(); }

    @Test
    void register_thenLogin_returnsAccessToken() throws Exception {
        register("alice", "password123");
        mockMvc.perform(post("/api/auth/login")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(
                new LoginRequest("alice", "password123"))))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.accessToken").isNotEmpty())
            .andExpect(jsonPath("$.expiresInMinutes").value(15));
    }

    @Test
    void protectedEndpoint_withoutToken_returns401() throws Exception {
        mockMvc.perform(get("/api/me"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void protectedEndpoint_withValidToken_returns200() throws Exception {
        register("bob", "password123");
        String token = getAccessToken("bob", "password123");
        mockMvc.perform(get("/api/me")
            .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.username").value("bob"));
    }

    private void register(String user, String pass) throws Exception {
        mockMvc.perform(post("/api/auth/register")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(
                new RegisterRequest(user, pass))))
            .andExpect(status().isOk());
    }

    private String getAccessToken(String user, String pass) throws Exception {
        MvcResult r = mockMvc.perform(post("/api/auth/login")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(
                new LoginRequest(user, pass)))).andReturn();
        return objectMapper.readTree(
            r.getResponse().getContentAsString()).get("accessToken").asText();
    }
}

Running the Application

# 1. Start Redis
docker run -d --name redis -p 6379:6379 redis:7-alpine

# 2. Run the app
mvn spring-boot:run

# 3. Register a user
curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"securepass1"}'

# 4. Login — refresh cookie saved to cookies.txt
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"securepass1"}' \
  -c cookies.txt

# 5. Call a protected endpoint
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"securepass1"}' | jq -r '.accessToken')
curl http://localhost:8080/api/me -H "Authorization: Bearer $TOKEN"

# 6. Rotate refresh token
curl -X POST http://localhost:8080/api/auth/refresh -b cookies.txt -c cookies.txt

# 7. Logout
curl -X POST http://localhost:8080/api/auth/logout -b cookies.txt

Common Pitfalls and Tips

Use PKCS#8 format for the private key. Spring Security's RSA loader expects PKCS#8. Use openssl genpkey (not the older openssl genrsa). If you already have a PKCS#1 key (-----BEGIN RSA PRIVATE KEY-----), convert it: openssl pkcs8 -topk8 -nocrypt -in old.pem -out new.pem.

CVE-2025-53864. Deeply nested JSON in a JWT claim set triggers StackOverflowError in nimbus-jose-jwt < 9.37.4. The 10.0.2 property in pom.xml pins to the patched release. Confirm with mvn dependency:tree | grep nimbus.

Never store tokens in localStorage. A single XSS exploit makes every localStorage entry readable. Keep the access token in a JavaScript variable (process memory only) and the refresh token in an HttpOnly cookie set by the server.

BCrypt strength 12 in 2026. Modern hardware cracks BCrypt-10 offline in hours. Strength 12 takes ~300ms on a server CPU — imperceptible during login, but ~4x slower for an offline attacker than strength 10.

Scope the cookie to the refresh path. setPath("/api/auth/refresh") means the browser only sends the cookie to that exact endpoint. Even if another API endpoint has a vulnerability, it cannot access the refresh token.

Token family reuse detection means real logout-on-theft. If an attacker steals RT-1 and uses it after the legitimate user has rotated to RT-2, the entire family is invalidated — both the attacker's session and the legitimate user's session end immediately.

Conclusion

The two-token architecture with rotation solves the core tension in JWT security: tokens cannot be revoked, but nobody wants to re-authenticate every 15 minutes.

This implementation gives you:

  • Spring Boot 4.1 / Security 7 with native OAuth2 resource server support — no hand-rolled JWT logic

  • RS256 asymmetric signing — share the public key with microservices; keep the private key on the auth server only

  • Redis-backed refresh tokens with SHA-256 hashing and automatic TTL expiry

  • Token family reuse detection — theft becomes an auditable event that forces re-authentication

  • CVE-2025-53864 patched out of the box

The same public key can be used by multiple downstream services to verify tokens without touching the auth server — which is the real power of asymmetric JWT: decentralised verification with centralised issuance.