Cristhian Villegas
Cursos18 min read0 views

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

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

JWT Token Blacklisting with Redis

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.

What you will learn:
  • 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+:

java
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:

json
1{
2  "jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
3  "sub": "[email protected]",
4  "iat": 1712438400,
5  "exp": 1712439300
6}
Why UUID? A UUID v4 has 122 bits of randomness, giving you approximately 5.3 x 10^36 possible values. The probability of a collision is astronomically low — even generating a billion JTIs per second, you would need 100 years before reaching a 50% chance of a single collision. UUID is the industry standard for JTI values.

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.
The 15-minute myth: Many tutorials say "just use short-lived tokens and you will be fine." But in PCI-DSS, HIPAA, and SOC 2 compliant environments, auditors expect immediate revocation capabilities. A 15-minute window is a compliance finding, not a feature.

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:

StrategyRevocation SpeedPerformance ImpactComplexityBest For
Short expiry onlyUp to token TTL (minutes)NoneVery lowLow-risk apps, prototypes
Database blacklistInstantHigh (DB query per request)MediumSmall-scale apps
Redis blacklistInstantLow (~1ms per check)MediumProduction systems
Token versioningInstantMedium (DB query per request)HighMulti-device management
Event-based (pub/sub)Near-instantLowVery highDistributed 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.

Why not a database blacklist? Because the blacklist is checked on every single request. With a relational database, that means one extra query per request — potentially millions per day. Redis handles this effortlessly because it is an in-memory data store designed for exactly this type of high-throughput, low-latency lookup.

Setting Up Redis in Spring Boot

First, add the Redis dependency to your pom.xml:

xml
1<dependency>
2    <groupId>org.springframework.boot</groupId>
3    <artifactId>spring-boot-starter-data-redis</artifactId>
4</dependency>

Configure Redis in application.yml:

yaml
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:

java
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:

yaml
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:
Connection pooling matters. The Lettuce pool configuration above ensures that Redis connections are reused rather than created per request. In a production application handling thousands of requests per second, connection pooling prevents Redis from becoming a bottleneck.

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.

java
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.
Redis availability is critical. If Redis goes down, your blacklist check will fail. You must decide on your failure mode: fail open (allow all requests if Redis is unavailable — less secure but more available) or fail closed (reject all requests if Redis is unavailable — more secure but service disruption). For financial and healthcare applications, fail closed is the correct choice.

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.

java
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:

  1. 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.
  2. Signature verification: Verify the JWT signature using the secret key. If the signature is invalid (tampered token), reject.
  3. Expiry check: Verify that the token has not expired. If expired, reject.
Performance impact: Adding the Redis blacklist check adds approximately 1ms to every authenticated request. For most applications, this is negligible. If you process 10,000 requests per second, the total additional load is only 10 seconds of cumulative Redis time — well within what a single Redis instance can handle (it can process 100,000+ operations per second).

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.

java
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:

  1. Client sends POST /auth/logout with the access token in the Authorization header.
  2. Server extracts the JTI and expiration from the access token.
  3. Server adds the JTI to the Redis blacklist with a TTL matching the token's remaining lifetime.
  4. Server revokes all refresh tokens for the user in the database.
  5. Any subsequent request with the old access token is rejected by the filter.
Logout-all vs. single logout: The /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:

java
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:

java
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.
Always invalidate all sessions on password change. If the user is changing their password because their account was compromised, leaving other sessions active defeats the purpose. This is a common security oversight — many applications only invalidate the current session and leave others running.

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):

java
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:

java
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.
Memory estimation: Each blacklist entry in Redis uses approximately 100 bytes (key prefix + UUID + metadata). Even with 1 million blacklisted tokens, Redis only uses ~100 MB of memory. In practice, because entries auto-expire with TTL, you will rarely have more than a few thousand entries at any time — well within the capacity of even a small Redis instance.

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

java
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

java
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}
Test isolation: For integration tests that require Redis, use Testcontainers to spin up a real Redis instance in Docker. This ensures your tests validate real Redis behavior (TTL, key expiration, connection handling) rather than relying on mocks that may not reflect production behavior. Add @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 @PreAuthorize and 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
Course Progress: Part 1 (JWT Fundamentals) ✅ → Part 2 (Refresh Tokens) ✅ → Part 3 (JTI + Redis Blacklisting) ✅ → Part 4 (Custom Claims + RBAC + Production)
Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

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.