How to Implement JWT Blacklist in Spring Boot 3 with Caffeine & Redis
This article explains how to build a JWT blacklist in Spring Boot 3, covering token fingerprinting, storage options using Caffeine or Redis, request interception, configuration, and sample login/logout endpoints to revoke tokens before they expire.
1. Introduction
JWT is a common authentication method; revoking tokens before expiration is important. A JWT blacklist maintains a deny list of tokens that should be rejected.
2. Core components
Token fingerprint (jti) : use the JWT standard jti field as a unique identifier.
Storage for revoked tokens : in‑memory, Caffeine cache, or Redis.
Request interceptor : filter to check token validity.
3. Implementation steps
3.1 Utility classes
JwtUtils for generating and parsing tokens, and WebUtils for JSON error responses.
@Component
public class JwtUtils {
private static final String SECRET_KEY = "aaaabbbbccccdddd1111222233334444";
private static final SecretKeySpec KEY = new SecretKeySpec(SECRET_KEY.getBytes(), "HmacSHA256");
public String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.claims(claims)
.id(UUID.randomUUID().toString().replace("-", ""))
.subject("pack-xg-jwt")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 60 * 60 * 1000))
.signWith(KEY, Jwts.SIG.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(KEY)
.build()
.parseSignedClaims(token)
.getPayload();
}
public String getId(String token) {
try {
Claims claims = parseToken(token);
return claims.getId();
} catch (Exception e) {
return null;
}
}
} @Component
public class WebUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
public static void out(HttpServletResponse response, String error) throws IOException {
response.setContentType("application/json;charset=utf-8");
response.getWriter().print(objectMapper.writeValueAsString(Map.of("code", -1, "error", error)));
}
}3.2 Blacklist storage interface
public interface BlacklistStore {
void add(String jti, Instant expiresAt);
boolean contains(String jti);
}3.3 Caffeine implementation
public class CaffeineBlacklistStore implements BlacklistStore {
private final Cache<String, Instant> cache;
public CaffeineBlacklistStore() {
this.cache = Caffeine.newBuilder()
.expireAfter(new Expiry<String, Instant>() {
@Override public long expireAfterCreate(String key, Instant value, long currentTime) {
long remainingNanos = Duration.between(Instant.now(), value).toNanos();
return Math.max(0, remainingNanos);
}
@Override public long expireAfterUpdate(String key, Instant value, long currentTime, long currentDuration) {
return currentDuration;
}
@Override public long expireAfterRead(String key, Instant value, long currentTime, long currentDuration) {
return currentDuration;
}
})
.removalListener((key, value, cause) -> System.err.printf("Cache key=%s, value=%s removed, cause:%s%n", key, value, cause))
.build();
}
@Override public void add(String jti, Instant expiresAt) {
cache.put(jti, expiresAt);
}
@Override public boolean contains(String jti) {
return cache.getIfPresent(jti) != null;
}
}3.4 Redis implementation
public class RedisBlacklistStore implements BlacklistStore {
private final StringRedisTemplate stringRedisTemplate;
public RedisBlacklistStore(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override public void add(String jti, Instant expiresAt) {
if (jti == null || expiresAt == null) return;
Duration ttl = Duration.between(Instant.now(), expiresAt);
if (ttl.isNegative() || ttl.isZero()) return;
stringRedisTemplate.opsForValue().set(jti, "revoked", ttl);
}
@Override public boolean contains(String jti) {
return jti != null && Boolean.TRUE.equals(stringRedisTemplate.hasKey(jti));
}
}3.5 Configuration
pack:
jwt:
blacklist:
type: redis @Configuration
public class BlacklistStoreConfig {
@Bean
@ConditionalOnProperty(prefix = "pack.jwt.blacklist", name = "type", havingValue = "caffeine", matchIfMissing = true)
public CaffeineBlacklistStore caffeineBlacklistStore() {
return new CaffeineBlacklistStore();
}
@Bean
@ConditionalOnProperty(prefix = "pack.jwt.blacklist", name = "type", havingValue = "redis")
public RedisBlacklistStore redisBlacklistStore(StringRedisTemplate stringRedisTemplate) {
return new RedisBlacklistStore(stringRedisTemplate);
}
}3.6 Interceptor and filter
@Component
public class TokenInterceptor implements HandlerInterceptor {
private final JwtUtils jwtUtils;
public TokenInterceptor(JwtUtils jwtUtils) { this.jwtUtils = jwtUtils; }
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
WebUtils.out(response, "非法访问");
return false;
}
String token = header.substring(7);
try {
jwtUtils.parseToken(token);
} catch (Exception e) {
WebUtils.out(response, "无效Token");
return false;
}
return true;
}
} @Component
public class BlacklistFilter extends OncePerRequestFilter {
private final BlacklistStore blacklistStore;
private final JwtUtils jwtUtils;
public BlacklistFilter(BlacklistStore blacklistStore, JwtUtils jwtUtils) {
this.blacklistStore = blacklistStore;
this.jwtUtils = jwtUtils;
}
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Claims claims = jwtUtils.parseToken(token);
String jti = claims.getId();
if (jti == null || blacklistStore.contains(jti)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
WebUtils.out(response, "Token已被回收");
return;
}
} catch (JwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
WebUtils.out(response, "无效Token");
return;
}
}
chain.doFilter(request, response);
}
} @Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<BlacklistFilter> blacklistFilterRegistration(BlacklistFilter filter) {
FilterRegistrationBean<BlacklistFilter> reg = new FilterRegistrationBean<>(filter);
reg.addUrlPatterns("/*");
return reg;
}
}3.7 Login and logout endpoints
@RestController
@RequestMapping("/login")
public class LoginController {
private final JwtUtils jwtUtils;
public LoginController(JwtUtils jwtUtils) { this.jwtUtils = jwtUtils; }
@PostMapping
public ResponseEntity<?> login(String username) {
return ResponseEntity.ok(jwtUtils.generateToken(Map.of("username", username)));
}
} @RestController
@RequestMapping("/auth")
public class LogoutController {
private final BlacklistStore blacklistStore;
private final JwtUtils jwtUtils;
public LogoutController(BlacklistStore blacklistStore, JwtUtils jwtUtils) {
this.blacklistStore = blacklistStore;
this.jwtUtils = jwtUtils;
}
@PostMapping("/logout")
public ResponseEntity<String> logout(@RequestHeader("Authorization") String header) {
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Claims claims = jwtUtils.parseToken(token);
String jti = claims.getId();
Date exp = claims.getExpiration();
if (jti != null && exp != null) {
blacklistStore.add(jti, exp.toInstant());
}
return ResponseEntity.ok("success");
} catch (JwtException e) {
return ResponseEntity.ok("error: " + e.getMessage());
}
}
return ResponseEntity.ok("error");
}
}3.8 Verification endpoint
@RestController
@RequestMapping("/api")
public class ApiController {
@GetMapping("/query")
public ResponseEntity<?> query() {
return ResponseEntity.ok("查询用户...");
}
}After logging out, the token is added to the blacklist, and subsequent requests to protected APIs are rejected.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
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.
