Implement Refresh Token, Sliding Expiration, and Silent Renewal in Spring Boot 3
This guide shows how to integrate Spring Security 6 and OAuth2 in Spring Boot 3.x to implement three token renewal strategies—refresh tokens, sliding expiration, and silent renewal—while adding production‑grade security features such as Redis blacklisting and configurable thresholds.
Project Dependencies
<dependencies>
<!-- Spring Security + OAuth2 support -->
<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-oauth2-client</artifactId>
</dependency>
<!-- Redis for token blacklist and metadata -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Nimbus JWT library -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
</dependencies>JWT Encoder / Decoder Configuration (Public & Private Keys)
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}
@Bean
public JwtEncoder jwtEncoder() {
JWK jwk = new RSAKey.Builder(publicKey).privateKey(privateKey).build();
JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwks);
}Spring Security Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private SlidingExpirationFilter slidingExpirationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/token", "/auth/refresh").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.addFilterBefore(slidingExpirationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}Token Service (Generate, Parse & Blacklist)
@Service
public class TokenService {
@Value("${jwt.expiration}")
private long jwtExpiration;
@Value("${jwt.refresh-expiration}")
private long refreshExpiration;
@Autowired
private JwtEncoder jwtEncoder;
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String TOKEN_BLACKLIST = "token:blacklist:";
public String generateAccessToken(UserDetails userDetails) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("your-issuer")
.issuedAt(now)
.expiresAt(now.plusMillis(jwtExpiration))
.subject(userDetails.getUsername())
.claim("scope", "ROLE_USER")
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
public String generateRefreshToken(UserDetails userDetails) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("your-issuer")
.issuedAt(now)
.expiresAt(now.plusMillis(refreshExpiration))
.subject(userDetails.getUsername())
.claim("token_type", "refresh")
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
public void blacklistToken(String token, long expiresInSeconds) {
redisTemplate.opsForValue().set(TOKEN_BLACKLIST + token, "1", expiresInSeconds, TimeUnit.SECONDS);
}
public boolean isTokenBlacklisted(String token) {
return Boolean.TRUE.equals(redisTemplate.hasKey(TOKEN_BLACKLIST + token));
}
}Refresh Token Endpoint
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private TokenService tokenService;
@Autowired
private JwtDecoder jwtDecoder;
@Autowired
private UserDetailsService userDetailsService;
@PostMapping("/refresh")
public ResponseEntity<JwtResponse> refreshToken(@RequestBody RefreshRequest request) {
try {
Jwt jwt = jwtDecoder.decode(request.getRefreshToken());
if (!"refresh".equals(jwt.getClaims().get("token_type"))) {
throw new JwtException("Invalid token type");
}
if (tokenService.isTokenBlacklisted(request.getRefreshToken())) {
throw new JwtException("Token already used");
}
UserDetails userDetails = userDetailsService.loadUserByUsername(jwt.getSubject());
String newAccessToken = tokenService.generateAccessToken(userDetails);
String newRefreshToken = tokenService.generateRefreshToken(userDetails);
long ttl = jwt.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond();
tokenService.blacklistToken(request.getRefreshToken(), ttl);
return ResponseEntity.ok(new JwtResponse(newAccessToken, newRefreshToken, "Bearer", jwt.getExpiresAt().getEpochSecond()));
} catch (JwtException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}Sliding Expiration Filter
@Component
public class SlidingExpirationFilter extends OncePerRequestFilter {
@Value("${jwt.sliding-threshold-seconds:300}")
private long slidingThresholdSeconds;
@Autowired
private JwtDecoder jwtDecoder;
@Autowired
private TokenService tokenService;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
if (tokenService.isTokenBlacklisted(token)) {
throw new JwtException("Blacklisted token");
}
Jwt jwt = jwtDecoder.decode(token);
long expiresIn = jwt.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond();
if (expiresIn < slidingThresholdSeconds) {
UserDetails userDetails = userDetailsService.loadUserByUsername(jwt.getSubject());
String newToken = tokenService.generateAccessToken(userDetails);
response.setHeader("X-Renewed-Token", newToken);
}
} catch (JwtException ignored) {}
}
filterChain.doFilter(request, response);
}
}Silent Renewal (Frontend JavaScript)
function checkTokenExpiration() {
const token = getTokenFromStorage();
if (token && isTokenExpiringSoon(token)) {
renewTokenSilently();
}
}
function renewTokenSilently() {
const refreshToken = getRefreshTokenFromStorage();
fetch('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
headers: { 'Content-Type': 'application/json' }
})
.then(res => res.json())
.then(data => storeNewTokens(data.accessToken, data.refreshToken))
.catch(() => redirectToLogin());
}
setInterval(checkTokenExpiration, 60000); // check every minuteApplication Properties Example
# Token lifetimes (milliseconds)
jwt.expiration=900000 # 15 minutes
jwt.refresh-expiration=2592000000 # 30 days
jwt.sliding-threshold-seconds=300 # sliding window threshold (seconds)
# Redis connection
spring.redis.host=127.0.0.1
spring.redis.port=6379Best‑Practice Recommendations
Access tokens should be short‑lived (e.g., 15 minutes) while refresh tokens have a longer lifespan (e.g., 30 days).
Refresh tokens must be single‑use; invalidate them immediately after a successful refresh.
Maintain a Redis‑backed blacklist to support immediate revocation and logout.
Make the sliding‑expiration threshold configurable to avoid unnecessary token renewals.
Bind refresh tokens to client‑specific data (client_id, device_id, IP) for cross‑device validation.
When running multiple application instances, share the same Redis instance to keep blacklist state consistent.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Ray's Galactic Tech
Practice together, never alone. We cover programming languages, development tools, learning methods, and pitfall notes. We simplify complex topics, guiding you from beginner to advanced. Weekly practical content—let's grow together!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
