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.
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.
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.
