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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
How to Implement JWT Blacklist in Spring Boot 3 with Caffeine & Redis

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.

RedisCaffeineJWTBlacklistspring-bootToken Revocation
Spring Full-Stack Practical Cases
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.