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()+STATELESSsessions is correct for a JWT REST API — no server-side session means no CSRF surfaceoauth2ResourceServer()wires Spring Security's built-in JWT validation pipeline — no customOncePerRequestFilterneededNimbusJwtDecoder.withPublicKey()explicitly enforces RS256;alg:noneand algorithm-confusion attacks are rejected at the library level@EnableMethodSecurityreplaces 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:
- RT-1 is marked
revoked: truein Redis - 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:
- Redis shows
revoked: true— it was already used - All tokens in the family are immediately revoked — RT-2 and every subsequent token become invalid
- 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 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.