JWT in Spring Boot 3 Course #3: JTI, Redis Blacklisting and Token Revocation


Source: Taylor Vick — Unsplash
Part 3 of 4 — JTI and Redis Blacklisting
Welcome back to the JWT in Spring Boot 3 Course. In Part 1, we built a complete JWT authentication system with access tokens, a custom filter, and Spring Security 6 integration. In Part 2, we added refresh tokens with secure rotation and reuse detection, solving the user experience problem of short-lived access tokens.
But there is still a critical gap in our architecture: we cannot revoke an access token before it expires. If a user logs out, changes their password, or an admin disables an account, the old access token remains valid for up to 15 minutes. In security-sensitive applications — banking, healthcare, enterprise — that window is unacceptable.
In this article, we solve that problem with two powerful techniques: the JTI (JWT ID) claim and a Redis-based token blacklist. Together, they give us instant token revocation while preserving the performance benefits of stateless JWTs.
- How the JTI (JWT ID) claim uniquely identifies every token
- Why stateless JWTs create a revocation problem and how to solve it
- Setting up Redis in a Spring Boot 3 application
- Building a TokenBlacklistService with automatic TTL expiration
- Integrating the blacklist check into the JWT authentication filter
- Implementing real logout and password-change revocation flows
- Monitoring and testing the blacklist system
What Is JTI (JWT ID)?
The JTI (JWT ID) is a registered claim defined in RFC 7519, Section 4.1.7. It provides a unique identifier for each JWT, ensuring that no two tokens share the same ID. Think of it as a serial number for your tokens.
Without a JTI, every token for the same user with the same expiration time is essentially identical — there is no way to distinguish between them or target a specific token for revocation. The JTI solves this by making every token individually addressable.
Here is how to add a JTI claim when generating tokens with JJWT 0.12+:
1package com.example.auth.service;
2
3import io.jsonwebtoken.Jwts;
4import io.jsonwebtoken.security.Keys;
5import org.springframework.beans.factory.annotation.Value;
6import org.springframework.security.core.userdetails.UserDetails;
7import org.springframework.stereotype.Service;
8
9import javax.crypto.SecretKey;
10import java.nio.charset.StandardCharsets;
11import java.time.Instant;
12import java.util.Date;
13import java.util.UUID;
14
15@Service
16public class JwtService {
17
18 private final SecretKey signingKey;
19 private final long accessTokenExpirationMs;
20
21 public JwtService(
22 @Value("${app.security.jwt.secret}") String secret,
23 @Value("${app.security.jwt.expiration-ms:900000}") long accessTokenExpirationMs) {
24 this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
25 this.accessTokenExpirationMs = accessTokenExpirationMs;
26 }
27
28 public String generateToken(UserDetails userDetails) {
29 Instant now = Instant.now();
30 Instant expiry = now.plusMillis(accessTokenExpirationMs);
31
32 return Jwts.builder()
33 .id(UUID.randomUUID().toString()) // JTI — unique token identifier
34 .subject(userDetails.getUsername())
35 .issuedAt(Date.from(now))
36 .expiration(Date.from(expiry))
37 .signWith(signingKey)
38 .compact();
39 }
40
41 public String extractJti(String token) {
42 return Jwts.parser()
43 .verifyWith(signingKey)
44 .build()
45 .parseSignedClaims(token)
46 .getPayload()
47 .getId(); // Extracts the "jti" claim
48 }
49
50 public String extractUsername(String token) {
51 return Jwts.parser()
52 .verifyWith(signingKey)
53 .build()
54 .parseSignedClaims(token)
55 .getPayload()
56 .getSubject();
57 }
58
59 public Date extractExpiration(String token) {
60 return Jwts.parser()
61 .verifyWith(signingKey)
62 .build()
63 .parseSignedClaims(token)
64 .getPayload()
65 .getExpiration();
66 }
67
68 public boolean isTokenValid(String token, UserDetails userDetails) {
69 String username = extractUsername(token);
70 return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
71 }
72
73 private boolean isTokenExpired(String token) {
74 return extractExpiration(token).before(new Date());
75 }
76
77 public long getExpirationMs() {
78 return accessTokenExpirationMs;
79 }
80}
A decoded JWT with JTI looks like this:
1{
2 "jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
3 "sub": "[email protected]",
4 "iat": 1712438400,
5 "exp": 1712439300
6}
The JWT Revocation Problem
JWTs are stateless by design. Once issued, the server does not need to store or look up anything to validate them — it simply checks the signature and expiration. This is what makes JWTs fast and scalable: no database call on every request.
But statelessness is a double-edged sword. Because the server has no memory of issued tokens, it also has no way to invalidate them before they expire. Consider these real-world scenarios where immediate revocation is necessary:
- User logs out: The user clicks "Sign Out" expecting their session to end immediately. Without revocation, anyone who captured the access token can still use it for up to 15 minutes.
- Password change: When a user changes their password (perhaps because they suspect their account was compromised), all existing sessions should be invalidated. A stateless JWT does not know or care that the password changed.
- Compromised account: A security team detects suspicious activity and needs to terminate all sessions for a user instantly. With pure stateless JWTs, they must wait for every token to expire naturally.
- Admin force-logout: An administrator needs to revoke access for a terminated employee. In a regulated environment, waiting 15 minutes is not an option.
- Permission change: A user's role is downgraded from admin to viewer. The old access token still carries the admin role in its claims until it expires.
Revocation Strategies Compared
There are several approaches to solving the JWT revocation problem. Each comes with different trade-offs in terms of complexity, performance, and security guarantees:
| Strategy | Revocation Speed | Performance Impact | Complexity | Best For |
|---|---|---|---|---|
| Short expiry only | Up to token TTL (minutes) | None | Very low | Low-risk apps, prototypes |
| Database blacklist | Instant | High (DB query per request) | Medium | Small-scale apps |
| Redis blacklist | Instant | Low (~1ms per check) | Medium | Production systems |
| Token versioning | Instant | Medium (DB query per request) | High | Multi-device management |
| Event-based (pub/sub) | Near-instant | Low | Very high | Distributed microservices |
We will implement the Redis blacklist strategy because it offers the best balance: instant revocation, sub-millisecond lookups, and automatic cleanup via TTL expiration. Redis keeps revoked JTIs in memory, so every request only adds ~1ms of latency — negligible compared to the 50-200ms of a typical database query.
Setting Up Redis in Spring Boot
First, add the Redis dependency to your pom.xml:
1<dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-data-redis</artifactId>
4</dependency>
Configure Redis in application.yml:
1spring:
2 data:
3 redis:
4 host: ${REDIS_HOST:localhost}
5 port: ${REDIS_PORT:6379}
6 password: ${REDIS_PASSWORD:}
7 timeout: 2000ms
8 lettuce:
9 pool:
10 max-active: 10
11 max-idle: 5
12 min-idle: 2
13 max-wait: 1000ms
14
15app:
16 security:
17 jwt:
18 secret: ${JWT_SECRET:your-256-bit-secret-key-here-minimum-32-characters}
19 expiration-ms: 900000 # 15 minutes
20 refresh-token:
21 expiration-ms: 604800000 # 7 days
Create a RedisTemplate bean configured for String key-value operations:
1package com.example.auth.config;
2
3import org.springframework.context.annotation.Bean;
4import org.springframework.context.annotation.Configuration;
5import org.springframework.data.redis.connection.RedisConnectionFactory;
6import org.springframework.data.redis.core.RedisTemplate;
7import org.springframework.data.redis.serializer.StringRedisSerializer;
8
9@Configuration
10public class RedisConfig {
11
12 @Bean
13 public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
14 RedisTemplate<String, String> template = new RedisTemplate<>();
15 template.setConnectionFactory(connectionFactory);
16 template.setKeySerializer(new StringRedisSerializer());
17 template.setValueSerializer(new StringRedisSerializer());
18 template.setHashKeySerializer(new StringRedisSerializer());
19 template.setHashValueSerializer(new StringRedisSerializer());
20 template.afterPropertiesSet();
21 return template;
22 }
23}
For local development, add a Redis service to your docker-compose.yml:
1services:
2 redis:
3 image: redis:7-alpine
4 container_name: auth-redis
5 ports:
6 - "6379:6379"
7 command: redis-server --requirepass ${REDIS_PASSWORD:-changeme}
8 volumes:
9 - redis-data:/data
10 healthcheck:
11 test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-changeme}", "ping"]
12 interval: 10s
13 timeout: 5s
14 retries: 3
15
16volumes:
17 redis-data:
Implementing the Blacklist Service
The TokenBlacklistService is the core of our revocation system. It stores blacklisted JTIs in Redis with a TTL (time-to-live) that matches the token's remaining lifetime. When the token would have expired naturally, Redis automatically deletes the entry — no manual cleanup required.
1package com.example.auth.service;
2
3import org.slf4j.Logger;
4import org.slf4j.LoggerFactory;
5import org.springframework.data.redis.core.RedisTemplate;
6import org.springframework.stereotype.Service;
7
8import java.time.Duration;
9import java.time.Instant;
10import java.util.Date;
11import java.util.Set;
12
13@Service
14public class TokenBlacklistService {
15
16 private static final Logger log = LoggerFactory.getLogger(TokenBlacklistService.class);
17 private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
18
19 private final RedisTemplate<String, String> redisTemplate;
20
21 public TokenBlacklistService(RedisTemplate<String, String> redisTemplate) {
22 this.redisTemplate = redisTemplate;
23 }
24
25 /**
26 * Blacklists a token by its JTI. The entry auto-expires when the token
27 * would have expired naturally, so Redis never accumulates stale entries.
28 *
29 * @param jti the unique JWT ID
30 * @param tokenExpiration the token's expiration date
31 */
32 public void blacklistToken(String jti, Date tokenExpiration) {
33 String key = BLACKLIST_PREFIX + jti;
34 Duration ttl = Duration.between(Instant.now(), tokenExpiration.toInstant());
35
36 if (ttl.isNegative() || ttl.isZero()) {
37 log.debug("Token {} already expired, skipping blacklist", jti);
38 return;
39 }
40
41 redisTemplate.opsForValue().set(key, "revoked", ttl);
42 log.info("Blacklisted token jti={} ttl={}s", jti, ttl.getSeconds());
43 }
44
45 /**
46 * Checks if a token's JTI has been blacklisted.
47 *
48 * @param jti the unique JWT ID
49 * @return true if the token is blacklisted (revoked)
50 */
51 public boolean isBlacklisted(String jti) {
52 String key = BLACKLIST_PREFIX + jti;
53 Boolean exists = redisTemplate.hasKey(key);
54 return Boolean.TRUE.equals(exists);
55 }
56
57 /**
58 * Returns the current number of blacklisted tokens in Redis.
59 * Useful for monitoring and alerting.
60 */
61 public long getBlacklistSize() {
62 Set<String> keys = redisTemplate.keys(BLACKLIST_PREFIX + "*");
63 return keys != null ? keys.size() : 0;
64 }
65}
Let us walk through the design decisions:
- Key prefix: All blacklist entries use the
jwt:blacklist:prefix. This makes it easy to distinguish blacklist keys from other Redis data and enables pattern-based queries for monitoring. - TTL = remaining token lifetime: If a token was issued with a 15-minute expiry and we blacklist it 5 minutes in, the Redis entry has a 10-minute TTL. After those 10 minutes, the token would have expired anyway, so there is no reason to keep the blacklist entry. This is self-cleaning by design.
- Skip expired tokens: If someone tries to blacklist a token that has already expired, we skip the Redis write entirely — there is nothing to revoke.
- Value is irrelevant: We store the string "revoked" as the value, but we only ever check for key existence. The value could be anything — we chose "revoked" for debuggability when inspecting Redis directly.
Integrating Blacklist in the JWT Filter
Now we update the JwtAuthenticationFilter to check the blacklist on every request. If the token's JTI appears in Redis, the request is rejected immediately — even if the JWT signature and expiry are valid.
1package com.example.auth.filter;
2
3import com.example.auth.service.JwtService;
4import com.example.auth.service.TokenBlacklistService;
5import jakarta.servlet.FilterChain;
6import jakarta.servlet.ServletException;
7import jakarta.servlet.http.HttpServletRequest;
8import jakarta.servlet.http.HttpServletResponse;
9import org.slf4j.Logger;
10import org.slf4j.LoggerFactory;
11import org.springframework.lang.NonNull;
12import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
13import org.springframework.security.core.context.SecurityContextHolder;
14import org.springframework.security.core.userdetails.UserDetails;
15import org.springframework.security.core.userdetails.UserDetailsService;
16import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
17import org.springframework.stereotype.Component;
18import org.springframework.web.filter.OncePerRequestFilter;
19
20import java.io.IOException;
21
22@Component
23public class JwtAuthenticationFilter extends OncePerRequestFilter {
24
25 private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
26
27 private final JwtService jwtService;
28 private final TokenBlacklistService tokenBlacklistService;
29 private final UserDetailsService userDetailsService;
30
31 public JwtAuthenticationFilter(JwtService jwtService,
32 TokenBlacklistService tokenBlacklistService,
33 UserDetailsService userDetailsService) {
34 this.jwtService = jwtService;
35 this.tokenBlacklistService = tokenBlacklistService;
36 this.userDetailsService = userDetailsService;
37 }
38
39 @Override
40 protected void doFilterInternal(@NonNull HttpServletRequest request,
41 @NonNull HttpServletResponse response,
42 @NonNull FilterChain filterChain)
43 throws ServletException, IOException {
44
45 String authHeader = request.getHeader("Authorization");
46
47 if (authHeader == null || !authHeader.startsWith("Bearer ")) {
48 filterChain.doFilter(request, response);
49 return;
50 }
51
52 String token = authHeader.substring(7);
53
54 try {
55 String username = jwtService.extractUsername(token);
56 String jti = jwtService.extractJti(token);
57
58 // Step 1: Check if the token has been blacklisted (revoked)
59 if (tokenBlacklistService.isBlacklisted(jti)) {
60 log.warn("Rejected blacklisted token jti={} user={}", jti, username);
61 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
62 response.getWriter().write("{"error":"Token has been revoked"}");
63 response.setContentType("application/json");
64 return;
65 }
66
67 // Step 2: Validate signature and expiry, then set SecurityContext
68 if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
69 UserDetails userDetails = userDetailsService.loadUserByUsername(username);
70
71 if (jwtService.isTokenValid(token, userDetails)) {
72 UsernamePasswordAuthenticationToken authToken =
73 new UsernamePasswordAuthenticationToken(
74 userDetails, null, userDetails.getAuthorities());
75 authToken.setDetails(
76 new WebAuthenticationDetailsSource().buildDetails(request));
77 SecurityContextHolder.getContext().setAuthentication(authToken);
78 }
79 }
80 } catch (Exception e) {
81 log.debug("JWT validation failed: {}", e.getMessage());
82 }
83
84 filterChain.doFilter(request, response);
85 }
86}
The filter now performs three checks in order:
- Blacklist check: Extract the JTI and query Redis. If blacklisted, return 401 immediately. This is the fastest check (~1ms) and short-circuits all further processing.
- Signature verification: Verify the JWT signature using the secret key. If the signature is invalid (tampered token), reject.
- Expiry check: Verify that the token has not expired. If expired, reject.
Real Logout Endpoint
With the blacklist in place, we can now implement a true logout that immediately invalidates the access token. Previously, our logout endpoint (from Part 2) only revoked the refresh token — the access token remained valid until expiry. Now we revoke both.
1package com.example.auth.controller;
2
3import com.example.auth.dto.AuthRequest;
4import com.example.auth.dto.AuthResponse;
5import com.example.auth.dto.RefreshRequest;
6import com.example.auth.entity.RefreshToken;
7import com.example.auth.service.JwtService;
8import com.example.auth.service.RefreshTokenService;
9import com.example.auth.service.TokenBlacklistService;
10import jakarta.servlet.http.HttpServletRequest;
11import jakarta.validation.Valid;
12import org.springframework.http.ResponseEntity;
13import org.springframework.security.authentication.AuthenticationManager;
14import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
15import org.springframework.security.core.Authentication;
16import org.springframework.security.core.annotation.AuthenticationPrincipal;
17import org.springframework.security.core.userdetails.UserDetails;
18import org.springframework.security.core.userdetails.UserDetailsService;
19import org.springframework.web.bind.annotation.*;
20
21import java.util.Date;
22import java.util.Map;
23
24@RestController
25@RequestMapping("/auth")
26public class AuthController {
27
28 private final AuthenticationManager authenticationManager;
29 private final JwtService jwtService;
30 private final RefreshTokenService refreshTokenService;
31 private final TokenBlacklistService tokenBlacklistService;
32 private final UserDetailsService userDetailsService;
33
34 public AuthController(AuthenticationManager authenticationManager,
35 JwtService jwtService,
36 RefreshTokenService refreshTokenService,
37 TokenBlacklistService tokenBlacklistService,
38 UserDetailsService userDetailsService) {
39 this.authenticationManager = authenticationManager;
40 this.jwtService = jwtService;
41 this.refreshTokenService = refreshTokenService;
42 this.tokenBlacklistService = tokenBlacklistService;
43 this.userDetailsService = userDetailsService;
44 }
45
46 @PostMapping("/login")
47 public ResponseEntity<AuthResponse> login(@Valid @RequestBody AuthRequest request) {
48 Authentication auth = authenticationManager.authenticate(
49 new UsernamePasswordAuthenticationToken(request.username(), request.password())
50 );
51 UserDetails userDetails = (UserDetails) auth.getPrincipal();
52 String accessToken = jwtService.generateToken(userDetails);
53 RefreshToken refreshToken = refreshTokenService.createRefreshToken(
54 getUserId(userDetails)
55 );
56 return ResponseEntity.ok(new AuthResponse(
57 accessToken, refreshToken.getToken(), jwtService.getExpirationMs()
58 ));
59 }
60
61 @PostMapping("/refresh")
62 public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshRequest request) {
63 RefreshToken oldToken = refreshTokenService.verifyRefreshToken(request.refreshToken());
64 RefreshToken newRefresh = refreshTokenService.rotateRefreshToken(oldToken);
65 UserDetails userDetails = userDetailsService.loadUserByUsername(
66 getUsernameByUserId(oldToken.getUserId())
67 );
68 String newAccess = jwtService.generateToken(userDetails);
69 return ResponseEntity.ok(new AuthResponse(
70 newAccess, newRefresh.getToken(), jwtService.getExpirationMs()
71 ));
72 }
73
74 /**
75 * True logout: blacklists the current access token AND revokes all refresh tokens.
76 */
77 @PostMapping("/logout")
78 public ResponseEntity<Map<String, String>> logout(HttpServletRequest request) {
79 String authHeader = request.getHeader("Authorization");
80 if (authHeader != null && authHeader.startsWith("Bearer ")) {
81 String token = authHeader.substring(7);
82 String jti = jwtService.extractJti(token);
83 Date expiration = jwtService.extractExpiration(token);
84
85 // Blacklist the access token so it cannot be used again
86 tokenBlacklistService.blacklistToken(jti, expiration);
87
88 // Also revoke all refresh tokens for this user
89 String username = jwtService.extractUsername(token);
90 Long userId = getUserIdByUsername(username);
91 refreshTokenService.revokeAllUserTokens(userId);
92 }
93 return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
94 }
95
96 /**
97 * Logout from ALL devices: blacklists the current access token
98 * and revokes every refresh token for the user.
99 */
100 @PostMapping("/logout-all")
101 public ResponseEntity<Map<String, String>> logoutAll(HttpServletRequest request) {
102 String authHeader = request.getHeader("Authorization");
103 if (authHeader != null && authHeader.startsWith("Bearer ")) {
104 String token = authHeader.substring(7);
105 String jti = jwtService.extractJti(token);
106 Date expiration = jwtService.extractExpiration(token);
107
108 tokenBlacklistService.blacklistToken(jti, expiration);
109
110 String username = jwtService.extractUsername(token);
111 Long userId = getUserIdByUsername(username);
112 refreshTokenService.revokeAllUserTokens(userId);
113 }
114 return ResponseEntity.ok(Map.of("message", "All sessions terminated"));
115 }
116
117 // Helper methods — implement based on your UserDetails / UserRepository
118 private Long getUserId(UserDetails userDetails) {
119 throw new UnsupportedOperationException("Implement based on your UserDetails");
120 }
121
122 private Long getUserIdByUsername(String username) {
123 throw new UnsupportedOperationException("Implement based on your UserRepository");
124 }
125
126 private String getUsernameByUserId(Long userId) {
127 throw new UnsupportedOperationException("Implement based on your UserRepository");
128 }
129}
The logout flow now works as follows:
- Client sends
POST /auth/logoutwith the access token in theAuthorizationheader. - Server extracts the JTI and expiration from the access token.
- Server adds the JTI to the Redis blacklist with a TTL matching the token's remaining lifetime.
- Server revokes all refresh tokens for the user in the database.
- Any subsequent request with the old access token is rejected by the filter.
/auth/logout endpoint blacklists only the current access token. The /auth/logout-all endpoint does the same but also revokes ALL refresh tokens, terminating every active session across all devices. Use logout-all when security is the priority (password change, compromised account).
Revocation on Password Change
When a user changes their password, all existing sessions must be invalidated immediately. This is a security requirement in virtually every compliance framework. Here is how to implement it:
1package com.example.auth.service;
2
3import com.example.auth.repository.UserRepository;
4import org.springframework.security.crypto.password.PasswordEncoder;
5import org.springframework.stereotype.Service;
6import org.springframework.transaction.annotation.Transactional;
7
8import java.util.Date;
9
10@Service
11public class UserService {
12
13 private final UserRepository userRepository;
14 private final PasswordEncoder passwordEncoder;
15 private final TokenBlacklistService tokenBlacklistService;
16 private final RefreshTokenService refreshTokenService;
17 private final JwtService jwtService;
18
19 public UserService(UserRepository userRepository,
20 PasswordEncoder passwordEncoder,
21 TokenBlacklistService tokenBlacklistService,
22 RefreshTokenService refreshTokenService,
23 JwtService jwtService) {
24 this.userRepository = userRepository;
25 this.passwordEncoder = passwordEncoder;
26 this.tokenBlacklistService = tokenBlacklistService;
27 this.refreshTokenService = refreshTokenService;
28 this.jwtService = jwtService;
29 }
30
31 @Transactional
32 public void changePassword(Long userId, String currentPassword,
33 String newPassword, String currentToken) {
34 var user = userRepository.findById(userId)
35 .orElseThrow(() -> new RuntimeException("User not found"));
36
37 // Verify the current password
38 if (!passwordEncoder.matches(currentPassword, user.getPassword())) {
39 throw new RuntimeException("Current password is incorrect");
40 }
41
42 // Update the password
43 user.setPassword(passwordEncoder.encode(newPassword));
44 userRepository.save(user);
45
46 // Blacklist the current access token
47 String jti = jwtService.extractJti(currentToken);
48 Date expiration = jwtService.extractExpiration(currentToken);
49 tokenBlacklistService.blacklistToken(jti, expiration);
50
51 // Revoke ALL refresh tokens — force re-authentication on every device
52 refreshTokenService.revokeAllUserTokens(userId);
53 }
54}
The password change controller endpoint:
1@PostMapping("/change-password")
2public ResponseEntity<Map<String, String>> changePassword(
3 @Valid @RequestBody ChangePasswordRequest request,
4 HttpServletRequest httpRequest,
5 @AuthenticationPrincipal UserDetails userDetails) {
6
7 String token = httpRequest.getHeader("Authorization").substring(7);
8 Long userId = getUserIdByUsername(userDetails.getUsername());
9
10 userService.changePassword(
11 userId,
12 request.currentPassword(),
13 request.newPassword(),
14 token
15 );
16
17 return ResponseEntity.ok(Map.of(
18 "message", "Password changed. Please log in again with your new password."
19 ));
20}
After a password change:
- The current access token is blacklisted in Redis — rejected on the next request.
- All refresh tokens are revoked in the database — no new access tokens can be generated.
- The user must authenticate again with the new password on every device.
Automatic Cleanup and Monitoring
One of Redis's greatest strengths for blacklisting is its native TTL support. Every blacklist entry automatically expires when the corresponding token would have expired — no cleanup job needed for the blacklist itself. However, we still want monitoring and housekeeping for other concerns.
Scheduled Cleanup for Refresh Tokens
The Redis blacklist is self-cleaning, but the PostgreSQL refresh token table still needs periodic purging (as we set up in Part 2):
1package com.example.auth.scheduler;
2
3import com.example.auth.service.RefreshTokenService;
4import com.example.auth.service.TokenBlacklistService;
5import org.slf4j.Logger;
6import org.slf4j.LoggerFactory;
7import org.springframework.scheduling.annotation.Scheduled;
8import org.springframework.stereotype.Component;
9
10@Component
11public class TokenMaintenanceScheduler {
12
13 private static final Logger log = LoggerFactory.getLogger(TokenMaintenanceScheduler.class);
14
15 private final RefreshTokenService refreshTokenService;
16 private final TokenBlacklistService tokenBlacklistService;
17
18 public TokenMaintenanceScheduler(RefreshTokenService refreshTokenService,
19 TokenBlacklistService tokenBlacklistService) {
20 this.refreshTokenService = refreshTokenService;
21 this.tokenBlacklistService = tokenBlacklistService;
22 }
23
24 /**
25 * Purge expired refresh tokens from PostgreSQL every day at 2:00 AM.
26 */
27 @Scheduled(cron = "0 0 2 * * ?")
28 public void purgeExpiredRefreshTokens() {
29 int deleted = refreshTokenService.purgeExpiredTokens();
30 log.info("Purged {} expired refresh tokens from database", deleted);
31 }
32
33 /**
34 * Log blacklist metrics every 5 minutes for monitoring dashboards.
35 */
36 @Scheduled(fixedRate = 300_000)
37 public void logBlacklistMetrics() {
38 long size = tokenBlacklistService.getBlacklistSize();
39 log.info("JWT blacklist size: {} entries in Redis", size);
40 }
41}
Actuator Metrics for Production Monitoring
Expose blacklist metrics through Spring Boot Actuator so your monitoring tools (Prometheus, Grafana, Datadog) can track them:
1package com.example.auth.metrics;
2
3import com.example.auth.service.TokenBlacklistService;
4import io.micrometer.core.instrument.Gauge;
5import io.micrometer.core.instrument.MeterRegistry;
6import org.springframework.stereotype.Component;
7
8@Component
9public class BlacklistMetrics {
10
11 public BlacklistMetrics(MeterRegistry meterRegistry,
12 TokenBlacklistService blacklistService) {
13 Gauge.builder("jwt.blacklist.size", blacklistService, TokenBlacklistService::getBlacklistSize)
14 .description("Number of blacklisted JWT tokens in Redis")
15 .tag("store", "redis")
16 .register(meterRegistry);
17 }
18}
With this configuration, you can set up alerts in your monitoring system:
- Warning: Blacklist size exceeds 10,000 entries (possible mass revocation event or attack).
- Critical: Redis connection failures (blacklist checks will fail, tokens cannot be revoked).
- Info: Track blacklist size over time to understand revocation patterns and capacity planning.
Testing the Blacklist
Thorough testing of the blacklist system is essential. A bug in the blacklist check could either lock out legitimate users or fail to revoke compromised tokens — both are serious issues.
Unit Tests with Mocked RedisTemplate
1package com.example.auth.service;
2
3import org.junit.jupiter.api.BeforeEach;
4import org.junit.jupiter.api.Test;
5import org.junit.jupiter.api.extension.ExtendWith;
6import org.mockito.ArgumentCaptor;
7import org.mockito.Mock;
8import org.mockito.junit.jupiter.MockitoExtension;
9import org.springframework.data.redis.core.RedisTemplate;
10import org.springframework.data.redis.core.ValueOperations;
11
12import java.time.Duration;
13import java.time.Instant;
14import java.time.temporal.ChronoUnit;
15import java.util.Date;
16
17import static org.assertj.core.api.Assertions.assertThat;
18import static org.mockito.ArgumentMatchers.*;
19import static org.mockito.Mockito.*;
20
21@ExtendWith(MockitoExtension.class)
22class TokenBlacklistServiceTest {
23
24 @Mock
25 private RedisTemplate<String, String> redisTemplate;
26
27 @Mock
28 private ValueOperations<String, String> valueOperations;
29
30 private TokenBlacklistService blacklistService;
31
32 @BeforeEach
33 void setUp() {
34 blacklistService = new TokenBlacklistService(redisTemplate);
35 }
36
37 @Test
38 void blacklistToken_validToken_storesInRedisWithTtl() {
39 // Arrange
40 String jti = "abc-123-def-456";
41 Date expiration = Date.from(Instant.now().plus(10, ChronoUnit.MINUTES));
42 when(redisTemplate.opsForValue()).thenReturn(valueOperations);
43
44 // Act
45 blacklistService.blacklistToken(jti, expiration);
46
47 // Assert
48 ArgumentCaptor<Duration> ttlCaptor = ArgumentCaptor.forClass(Duration.class);
49 verify(valueOperations).set(
50 eq("jwt:blacklist:abc-123-def-456"),
51 eq("revoked"),
52 ttlCaptor.capture()
53 );
54 assertThat(ttlCaptor.getValue().getSeconds()).isBetween(500L, 600L);
55 }
56
57 @Test
58 void blacklistToken_expiredToken_skipsRedisWrite() {
59 // Arrange
60 String jti = "expired-jti";
61 Date expiration = Date.from(Instant.now().minus(5, ChronoUnit.MINUTES));
62
63 // Act
64 blacklistService.blacklistToken(jti, expiration);
65
66 // Assert
67 verify(redisTemplate, never()).opsForValue();
68 }
69
70 @Test
71 void isBlacklisted_tokenExists_returnsTrue() {
72 // Arrange
73 when(redisTemplate.hasKey("jwt:blacklist:abc-123")).thenReturn(true);
74
75 // Act
76 boolean result = blacklistService.isBlacklisted("abc-123");
77
78 // Assert
79 assertThat(result).isTrue();
80 }
81
82 @Test
83 void isBlacklisted_tokenNotExists_returnsFalse() {
84 // Arrange
85 when(redisTemplate.hasKey("jwt:blacklist:xyz-789")).thenReturn(false);
86
87 // Act
88 boolean result = blacklistService.isBlacklisted("xyz-789");
89
90 // Assert
91 assertThat(result).isFalse();
92 }
93
94 @Test
95 void isBlacklisted_redisReturnsNull_returnsFalse() {
96 // Arrange
97 when(redisTemplate.hasKey(anyString())).thenReturn(null);
98
99 // Act
100 boolean result = blacklistService.isBlacklisted("null-case");
101
102 // Assert
103 assertThat(result).isFalse();
104 }
105}
Integration Test for the Logout Flow
1package com.example.auth.integration;
2
3import com.example.auth.service.JwtService;
4import com.example.auth.service.TokenBlacklistService;
5import org.junit.jupiter.api.Test;
6import org.springframework.beans.factory.annotation.Autowired;
7import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
8import org.springframework.boot.test.context.SpringBootTest;
9import org.springframework.security.core.userdetails.User;
10import org.springframework.security.core.userdetails.UserDetails;
11import org.springframework.test.web.servlet.MockMvc;
12
13import java.util.Collections;
14import java.util.Date;
15
16import static org.assertj.core.api.Assertions.assertThat;
17import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
18import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
19import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
20
21@SpringBootTest
22@AutoConfigureMockMvc
23class LogoutIntegrationTest {
24
25 @Autowired private MockMvc mockMvc;
26 @Autowired private JwtService jwtService;
27 @Autowired private TokenBlacklistService blacklistService;
28
29 @Test
30 void logout_shouldBlacklistTokenAndRejectSubsequentRequests() throws Exception {
31 // Arrange — generate a valid token
32 UserDetails user = User.builder()
33 .username("[email protected]")
34 .password("irrelevant")
35 .authorities(Collections.emptyList())
36 .build();
37 String token = jwtService.generateToken(user);
38 String jti = jwtService.extractJti(token);
39
40 // Verify the token works before logout
41 mockMvc.perform(get("/api/protected")
42 .header("Authorization", "Bearer " + token))
43 .andExpect(status().isOk());
44
45 // Act — call logout
46 mockMvc.perform(post("/auth/logout")
47 .header("Authorization", "Bearer " + token))
48 .andExpect(status().isOk());
49
50 // Assert — token is now blacklisted
51 assertThat(blacklistService.isBlacklisted(jti)).isTrue();
52
53 // Assert — subsequent requests with the same token are rejected
54 mockMvc.perform(get("/api/protected")
55 .header("Authorization", "Bearer " + token))
56 .andExpect(status().isUnauthorized());
57 }
58}
@Testcontainers and @Container annotations with GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379).
Next Steps
We now have a robust token revocation system: every JWT carries a unique JTI, and Redis provides instant blacklist lookups with automatic cleanup. Combined with the refresh token rotation from Part 2, our authentication system supports true logout, password-change revocation, and admin-initiated session termination.
In Part 4: Custom Claims, Role-Based Access Control, and Production Hardening, we will complete the course by covering:
- Adding custom claims to JWTs (roles, permissions, tenant ID)
- Implementing fine-grained RBAC with
@PreAuthorizeand method-level security - Rate limiting authentication endpoints to prevent brute-force attacks
- CORS configuration for single-page application frontends
- Key rotation strategies for zero-downtime secret changes
- Production checklist: HTTPS enforcement, security headers, logging best practices
Comments
Sign in to leave a comment
No comments yet. Be the first!
Related Articles
Stay updated
Get notified when I publish new articles. No spam, unsubscribe anytime.