Introduction
When building modern RESTful APIs with Spring Boot, securing endpoints effectively is crucial to safeguard user data and system integrity. JSON Web Tokens (JWT) provide a stateless, scalable authentication mechanism widely adopted in microservices and cloud-native architectures. However, relying solely on JWT access tokens without a robust refresh token strategy can expose your system to replay attacks and token misuse.
This guide targets senior Java developers and architects aiming to implement secure JWT authentication with refresh token rotation in Spring Boot applications using Spring Security. We'll provide a concrete, end-to-end example including token creation, storage, validation, and rotation, along with key considerations for security, performance, and operational maturity.
Prerequisites and Assumptions
- Java 11+ and Spring Boot 2.7+ environment
- Familiarity with Spring Security basics
- Basic knowledge of JWT principles
- Assumes use of a relational database (H2 for example) for refresh token persistence
- Uses
jjwt0.9.1 for JWT handling
Outcome
By the end, you will have a working Spring Boot authentication flow employing short-lived JWT access tokens alongside rotating refresh tokens stored securely in a database, mitigating token theft and replay risks.
End-to-end implementation
Core Concepts Recap
A JWT consists of three parts: header (metadata), payload (claims), and signature; signed with HMAC SHA-256 (HS256) in this example. Access tokens are short-lived and provided to clients after login. Refresh tokens are longer-lived, opaque tokens stored securely for getting fresh access tokens without re-authentication.
Refresh token rotation invalidates the refresh token after usage and issues a new one, thus limiting token reuse windows and allowing detection of compromised tokens.
Project Setup and Dependencies
Add these to your pom.xml to enable Spring Security, JPA, Web MVC, jjwt, and H2 for token persistence:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
Configure application properties for H2 datasource, JPA, and token expiration times:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
jwt.secret=yourSuperSecretKeyHere
jwt.accessExpirationMs=900000
jwt.refreshExpirationMs=2592000000
JWT Utility: Generation and Validation
Below is the JwtUtils component encapsulating JWT creation, claim extraction, and validation:
@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.accessExpirationMs}")
private long jwtExpirationMs;
public String generateAccessToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(SignatureAlgorithm.HS256, jwtSecret)
.compact();
}
public String getUsernameFromJwtToken(String token) {
return Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public boolean validateJwtToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (JwtException | IllegalArgumentException e) {
// Log invalid token with appropriate logging framework
return false;
}
}
}
This component generates an access token with a subject (username), signed with your secret key, valid for a short duration (e.g., 15 minutes). It also validates tokens by parsing and catching potential exceptions.
Refresh Token Entity and Repository
We define RefreshToken as a JPA entity to manage persistence and expiration:
@Entity
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String token;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false)
private Instant expiryDate;
// standard getters and setters
}
In addition, create Spring Data repositories for RefreshToken and User to enable CRUD operations.
RefreshTokenService: Managing Token Lifecycle
This service encapsulates creation, verification, rotation, and deletion of refresh tokens:
@Service
public class RefreshTokenService {
@Value("${jwt.refreshExpirationMs}")
private Long refreshTokenDurationMs;
@Autowired
private RefreshTokenRepository refreshTokenRepository;
@Autowired
private UserRepository userRepository;
public RefreshToken createRefreshToken(Long userId) {
RefreshToken refreshToken = new RefreshToken();
refreshToken.setUser(userRepository.findById(userId)
.orElseThrow(() -> new UsernameNotFoundException("User not found")));
refreshToken.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs));
refreshToken.setToken(UUID.randomUUID().toString());
return refreshTokenRepository.save(refreshToken);
}
public Optional<RefreshToken> findByToken(String token) {
return refreshTokenRepository.findByToken(token);
}
public RefreshToken verifyExpiration(RefreshToken token) {
if (token.getExpiryDate().isBefore(Instant.now())) {
refreshTokenRepository.delete(token);
throw new TokenRefreshException(token.getToken(), "Refresh token expired. Please log in again.");
}
return token;
}
public void deleteByToken(String token) {
refreshTokenRepository.findByToken(token)
.ifPresent(refreshTokenRepository::delete);
}
/**
* On logout or suspicious activity, delete all refresh tokens for the user.
*/
public int deleteByUserId(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return refreshTokenRepository.deleteByUser(user);
}
}
This service ensures refresh tokens are unique, expire as configured, and can be rotated or revoked safely. If a refresh token is expired, it will be deleted and an exception thrown prompting re-authentication.
Refresh Token Controller with Rotation Logic
The following REST endpoint implements the refresh token rotation sequence:
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private RefreshTokenService refreshTokenService;
/**
* Refresh JWT access and rotate refresh token.
*/
@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(@RequestBody TokenRefreshRequest request) {
String requestRefreshToken = request.getRefreshToken();
return refreshTokenService.findByToken(requestRefreshToken)
.map(refreshTokenService::verifyExpiration)
.map(RefreshToken::getUser)
.map(user -> {
String accessToken = jwtUtils.generateAccessToken(user.getUsername());
RefreshToken newRefreshToken = refreshTokenService.createRefreshToken(user.getId());
// Invalidate old refresh token immediately
refreshTokenService.deleteByToken(requestRefreshToken);
return ResponseEntity.ok(new TokenRefreshResponse(accessToken, newRefreshToken.getToken()));
})
.orElseThrow(() -> new TokenRefreshException(requestRefreshToken, "Refresh token not found in database."));
}
}
Here, when a client presents a refresh token:
- The request token is looked up and verified
- If valid, a new access token and a new refresh token are generated
- The old refresh token is deleted, preventing token replay
- New tokens are returned to the client
This rotation pattern drastically reduces risks from stolen refresh tokens.
Spring Security Configuration (Filter Chain)
Configure Spring Security to:
- Disable CSRF for stateless APIs
- Permit
/auth/**endpoints publicly - Secure all other endpoints
- Use a custom
JwtAuthenticationFilterto extract and validate JWT tokens
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserDetailsService userDetailsService;
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtUtils, userDetailsService);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
The JwtAuthenticationFilter extracts the JWT from the Authorization header, validates it, and sets authentication in the security context so protected endpoints know the user’s identity.
Verification and testing
Manual API Testing with Postman or Insomnia
- User Login: Send POST to
/api/auth/loginwith valid credentials.
- Expect JSON response with
accessTokenandrefreshToken.
- Protected Endpoint Access: Use the
accessTokenin theAuthorization: Bearer <token>header.
- Access protected resource; expect success response.
- Token Expiration: Wait or artificially expire the access token.
- Retry protected resource; expect
401 Unauthorized.
- Refresh Token Use: Send POST to
/api/auth/refreshwith the validrefreshToken.
- Expect new access and refresh tokens.
- Reuse Old Refresh Token: Attempt to call refresh endpoint again with the old refresh token.
- Expect failure due to token invalidation.
Expected Results
- Access tokens work until expiration.
- Refresh tokens can be used only once; subsequent reuse triggers error.
- Expired refresh tokens trigger proper exception and require login.
Automated Testing Suggestions
- Write unit tests for
JwtUtilsto cover token generation and validation edge cases. - Integration tests simulating refresh token rotation race conditions.
- Mock database latency and failures to verify resilience.
Failure modes and troubleshooting
- Token Expiration Issues: Confirm server system clocks are synchronized (e.g., use NTP).
- Invalid Signature Errors: Ensure
jwt.secretstays consistent across server instances. - Refresh Token Reuse: Detect reuse attempts by verifying token existence before deletion; implement alerts on detection.
- Concurrent Refresh Requests: Use database transactions or optimistic locking to prevent issuing multiple new refresh tokens simultaneously.
- Refresh Token Leak: Rotate refresh tokens frequently and monitor failed reuse attempts; implement forced logout on suspicious activity.
Security best practices mandate using HTTPS to prevent token interception and storing secrets securely (environment variables or vault).
Alternatives, trade-offs, and limitations
Alternatives
- Sessions: Store server-side sessions with cookies — simpler revocation but less scalable in distributed systems.
- Opaque Access Tokens: Instead of JWT access tokens, use random opaque tokens validated against a backend store — adds server state but simplifies immediate revocation.
- JWTs with Embedded Refresh Tokens: Some implement refresh tokens as JWTs; however, opaque tokens improve revocation control.
Trade-offs
- Stateless JWT Access + Stateful Refresh Tokens: Provides scalability and statelessness for access, combined with control over refresh tokens.
- Refresh Token Rotation: Adds complexity but dramatically increases security against token replay.
- Database Storage for Refresh Tokens: Introduces latency and requires scaling the database but offers token management flexibility.
Limitations
- JWT revocation is non-trivial since access tokens are stateless and valid until expiry.
- Refresh token reliance requires secure storage and handling to avoid introducing new attack vectors.
- Rotation requires careful synchronization to avoid race conditions; complexity increases in distributed environments.
Summary
Implementing JWT authentication with refresh token rotation in Spring Boot significantly strengthens your API’s security posture. Short-lived JWT access tokens minimize risk if intercepted, and rotating refresh tokens guard against replay attacks by invalidating tokens immediately after use.
This guide provided an end-to-end Spring Boot example using JPA-backed refresh tokens, illustrating the essential components and flow. Thoughtful implementation of token lifecycle management, combined with secure Spring Security configurations, ensures your authenticated APIs resist common attacks.
Remember to use HTTPS, protect your secrets, and monitor suspicious behavior. While this pattern adds complexity, it is a best practice for any production environment requiring scalable and secure stateless authentication.
FAQ
Why not just use sessions instead of JWT?
Sessions require server-side state, complicating horizontal scaling and microservices architectures. JWT tokens provide stateless authentication that scales well across distributed systems without maintaining session state.
How often should access tokens expire?
Access tokens should be short-lived, typically between 5 to 15 minutes, to minimize the window of exploitation if compromised. Refresh tokens are longer-lived but rotated frequently.
Can refresh tokens be JWTs too?
Yes, but storing refresh tokens as opaque random identifiers in a database is generally safer. This allows immediate revocation and rotation control, which is harder with JWT refresh tokens.
Sources and further reading
- JWT.io
- Spring Security Documentation
- RFC 7519 – JSON Web Token (JWT)
- OAuth 2.0 Token Revocation RFC 7009
