Cristhian Villegas
Cursos22 min read0 views

JWT in Spring Boot 3 Course #4: Custom Claims, RBAC and Production Security

JWT in Spring Boot 3 Course #4: Custom Claims, RBAC and Production Security

JWT Production Security

Source: Adi Goldstein — Unsplash

Part 4 of 4 — Custom Claims, RBAC and Production Security

Welcome to the final article in the JWT in Spring Boot 3 Course. Over the previous three parts, we have built a complete JWT authentication system from the ground up:

  • Part 1 — JWT Fundamentals: signing, validation, and the JwtAuthenticationFilter
  • Part 2 — Refresh Tokens: secure rotation, reuse detection, and HttpOnly cookies
  • Part 3 — JTI + Redis Blacklisting: instant token revocation and secure logout

In this final article, we take everything we have built and harden it for production. We will add custom claims to our JWTs, implement Role-Based Access Control (RBAC) with fine-grained permissions, explore signing algorithm choices, apply OWASP security guidelines, add rate limiting, implement security auditing, write comprehensive tests, and configure environment-specific profiles.

What you will learn in this final article:
  • Adding custom claims (roles, permissions, tenant_id) to JWTs
  • Building a complete RBAC system with Role and Permission entities
  • Securing endpoints with @PreAuthorize and method-level security
  • Choosing the right signing algorithm: HMAC vs RSA vs ECDSA
  • Applying OWASP Top 10 security practices to JWT implementations
  • Rate limiting authentication endpoints with Bucket4j
  • Security auditing with structured logging and MDC
  • Comprehensive testing with JUnit 5 and MockMvc
  • Environment-specific configuration for dev, QA, and production
Prerequisites: This article builds directly on the code from Parts 1-3. You need a working Spring Boot 3.2+ project with JWT authentication, refresh tokens, and Redis blacklisting already implemented. Java 17+ is required.

Custom Claims in JWT

So far, our JWTs contain only the subject (username) and standard claims like iat and exp. In production, you almost always need additional data embedded in the token — roles, permissions, tenant identifiers, or user metadata — so the server can make authorization decisions without hitting the database on every request.

Custom claims transform your JWT from a simple identity token into an authorization token that carries everything the server needs to make access decisions.

Adding Custom Claims with JJWT 0.12

Let us update our JwtService to support custom claims. We will add roles, permissions, and a tenant identifier:

java
1@Service
2public class JwtService {
3
4    @Value("${jwt.secret}")
5    private String secretKey;
6
7    @Value("${jwt.access-token-expiration}")
8    private long accessTokenExpiration;
9
10    private SecretKey getSigningKey() {
11        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
12        return Keys.hmacShaKeyFor(keyBytes);
13    }
14
15    public String generateToken(UserDetails userDetails, Map<String, Object> extraClaims) {
16        return Jwts.builder()
17                .subject(userDetails.getUsername())
18                .claim("roles", extractRoleNames(userDetails))
19                .claim("permissions", extractPermissions(userDetails))
20                .claim("tenant_id", extractTenantId(userDetails))
21                .claims(extraClaims)
22                .issuedAt(new Date())
23                .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
24                .id(UUID.randomUUID().toString()) // JTI for blacklisting
25                .signWith(getSigningKey())
26                .compact();
27    }
28
29    public String generateToken(UserDetails userDetails) {
30        return generateToken(userDetails, Map.of());
31    }
32
33    private List<String> extractRoleNames(UserDetails userDetails) {
34        return userDetails.getAuthorities().stream()
35                .map(GrantedAuthority::getAuthority)
36                .filter(auth -> auth.startsWith("ROLE_"))
37                .map(auth -> auth.substring(5)) // Remove "ROLE_" prefix
38                .toList();
39    }
40
41    private List<String> extractPermissions(UserDetails userDetails) {
42        return userDetails.getAuthorities().stream()
43                .map(GrantedAuthority::getAuthority)
44                .filter(auth -> !auth.startsWith("ROLE_"))
45                .toList();
46    }
47
48    private String extractTenantId(UserDetails userDetails) {
49        if (userDetails instanceof AppUser appUser) {
50            return appUser.getTenantId();
51        }
52        return "default";
53    }
54
55    // Extract custom claims from a token
56    @SuppressWarnings("unchecked")
57    public List<String> extractRoles(String token) {
58        Claims claims = extractAllClaims(token);
59        return claims.get("roles", List.class);
60    }
61
62    @SuppressWarnings("unchecked")
63    public List<String> extractPermissions(String token) {
64        Claims claims = extractAllClaims(token);
65        return claims.get("permissions", List.class);
66    }
67
68    public String extractTenantId(String token) {
69        Claims claims = extractAllClaims(token);
70        return claims.get("tenant_id", String.class);
71    }
72
73    private Claims extractAllClaims(String token) {
74        return Jwts.parser()
75                .verifyWith(getSigningKey())
76                .build()
77                .parseSignedClaims(token)
78                .getPayload();
79    }
80}
Never put sensitive data in JWT claims. The payload is only Base64-encoded, not encrypted. Anyone can decode it. Never include passwords, SSNs, credit card numbers, API keys, or any PII beyond what is strictly necessary for authorization. If you need to pass sensitive data, use an opaque token with server-side storage.

Decoded JWT Example

After adding custom claims, a decoded JWT payload looks like this:

json
1{
2  "sub": "[email protected]",
3  "roles": ["ADMIN", "USER"],
4  "permissions": ["USER_CREATE", "USER_READ", "USER_DELETE", "REPORT_GENERATE"],
5  "tenant_id": "tenant-acme-corp",
6  "iat": 1712419200,
7  "exp": 1712420100,
8  "jti": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
9}
Keep claims minimal. Every claim increases the token size, and the token is sent with every request. A JWT over 8 KB can cause issues with some reverse proxies and load balancers. If you have hundreds of permissions, consider using role-based claims and resolving permissions server-side.

Implementing RBAC (Role-Based Access Control)

Role-Based Access Control (RBAC) is the industry standard for managing user permissions. Instead of assigning permissions directly to users, you assign roles, and each role contains a set of permissions. This creates a clean hierarchy: User → Roles → Permissions.

JPA Entities

First, let us create the Role and Permission entities:

java
1@Entity
2@Table(name = "roles")
3public class Role {
4
5    @Id
6    @GeneratedValue(strategy = GenerationType.IDENTITY)
7    private Long id;
8
9    @Column(unique = true, nullable = false)
10    private String name; // ADMIN, USER, MODERATOR
11
12    @ManyToMany(fetch = FetchType.EAGER)
13    @JoinTable(
14        name = "role_permissions",
15        joinColumns = @JoinColumn(name = "role_id"),
16        inverseJoinColumns = @JoinColumn(name = "permission_id")
17    )
18    private Set<Permission> permissions = new HashSet<>();
19
20    // Constructors, getters, setters
21}
22
23@Entity
24@Table(name = "permissions")
25public class Permission {
26
27    @Id
28    @GeneratedValue(strategy = GenerationType.IDENTITY)
29    private Long id;
30
31    @Column(unique = true, nullable = false)
32    private String name; // USER_CREATE, USER_READ, REPORT_GENERATE
33
34    @Column
35    private String description;
36
37    // Constructors, getters, setters
38}
39
40@Entity
41@Table(name = "users")
42public class AppUser implements UserDetails {
43
44    @Id
45    @GeneratedValue(strategy = GenerationType.IDENTITY)
46    private Long id;
47
48    @Column(unique = true, nullable = false)
49    private String email;
50
51    @Column(nullable = false)
52    private String password;
53
54    @Column(name = "tenant_id")
55    private String tenantId;
56
57    @ManyToMany(fetch = FetchType.EAGER)
58    @JoinTable(
59        name = "user_roles",
60        joinColumns = @JoinColumn(name = "user_id"),
61        inverseJoinColumns = @JoinColumn(name = "role_id")
62    )
63    private Set<Role> roles = new HashSet<>();
64
65    @Override
66    public Collection<? extends GrantedAuthority> getAuthorities() {
67        Set<GrantedAuthority> authorities = new HashSet<>();
68
69        for (Role role : roles) {
70            // Add role as ROLE_ADMIN, ROLE_USER, etc.
71            authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName()));
72
73            // Add each permission as a separate authority
74            for (Permission permission : role.getPermissions()) {
75                authorities.add(new SimpleGrantedAuthority(permission.getName()));
76            }
77        }
78
79        return authorities;
80    }
81
82    @Override
83    public String getUsername() {
84        return email;
85    }
86
87    // Other UserDetails methods...
88}

Loading Authorities into JWT Claims

When a user logs in, Spring Security calls getAuthorities() on the UserDetails object. Our JwtService reads those authorities and splits them into roles and permissions claims. This means the JWT carries everything the server needs to authorize requests — no database lookup required for subsequent API calls.

Database schema: You need three join tables: user_roles (user_id, role_id), role_permissions (role_id, permission_id), and the main users, roles, and permissions tables. Use Flyway or Liquibase for schema migrations in production.

@PreAuthorize with JWT Claims

Spring Security's method-level security lets you protect individual controller methods based on the authorities embedded in the JWT. This is where our RBAC system truly shines.

Enable Method Security

First, enable method-level security in your configuration:

java
1@Configuration
2@EnableMethodSecurity(prePostEnabled = true)
3public class SecurityConfig {
4
5    private final JwtAuthenticationFilter jwtAuthFilter;
6    private final AuthenticationProvider authenticationProvider;
7
8    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
9                          AuthenticationProvider authenticationProvider) {
10        this.jwtAuthFilter = jwtAuthFilter;
11        this.authenticationProvider = authenticationProvider;
12    }
13
14    @Bean
15    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
16        return http
17            .csrf(AbstractHttpConfigurer::disable)
18            .sessionManagement(session ->
19                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
20            .authorizeHttpRequests(auth -> auth
21                .requestMatchers("/auth/**").permitAll()
22                .requestMatchers("/actuator/health").permitAll()
23                .anyRequest().authenticated()
24            )
25            .authenticationProvider(authenticationProvider)
26            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
27            .build();
28    }
29}

Securing Controllers

Now you can protect endpoints with @PreAuthorize annotations that reference the roles and permissions from the JWT:

java
1@RestController
2@RequestMapping("/api/users")
3public class UserController {
4
5    private final UserService userService;
6
7    public UserController(UserService userService) {
8        this.userService = userService;
9    }
10
11    @GetMapping
12    @PreAuthorize("hasAuthority('USER_READ')")
13    public ResponseEntity<List<UserResponse>> getAllUsers() {
14        return ResponseEntity.ok(userService.findAll());
15    }
16
17    @PostMapping
18    @PreAuthorize("hasAuthority('USER_CREATE')")
19    public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
20        return ResponseEntity.status(201).body(userService.create(request));
21    }
22
23    @DeleteMapping("/{id}")
24    @PreAuthorize("hasRole('ADMIN') and hasAuthority('USER_DELETE')")
25    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
26        userService.delete(id);
27        return ResponseEntity.noContent().build();
28    }
29
30    @GetMapping("/reports")
31    @PreAuthorize("hasRole('ADMIN') or hasAuthority('REPORT_GENERATE')")
32    public ResponseEntity<ReportResponse> generateReport() {
33        return ResponseEntity.ok(userService.generateReport());
34    }
35}
hasRole vs hasAuthority: hasRole('ADMIN') checks for the authority ROLE_ADMIN (Spring adds the ROLE_ prefix automatically). hasAuthority('USER_CREATE') checks for the exact string. Use hasRole for role-based checks and hasAuthority for permission-based checks.

Updating the JWT Filter for RBAC

The JwtAuthenticationFilter must reconstruct the authorities from the JWT claims so Spring Security can evaluate @PreAuthorize expressions:

java
1@Component
2public class JwtAuthenticationFilter extends OncePerRequestFilter {
3
4    private final JwtService jwtService;
5
6    public JwtAuthenticationFilter(JwtService jwtService) {
7        this.jwtService = jwtService;
8    }
9
10    @Override
11    protected void doFilterInternal(HttpServletRequest request,
12                                    HttpServletResponse response,
13                                    FilterChain filterChain) throws ServletException, IOException {
14
15        final String authHeader = request.getHeader("Authorization");
16
17        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
18            filterChain.doFilter(request, response);
19            return;
20        }
21
22        final String jwt = authHeader.substring(7);
23        final String username = jwtService.extractUsername(jwt);
24
25        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
26            if (jwtService.isTokenValid(jwt) && !jwtService.isTokenBlacklisted(jwt)) {
27                // Build authorities from JWT claims
28                List<GrantedAuthority> authorities = buildAuthorities(jwt);
29
30                UsernamePasswordAuthenticationToken authToken =
31                    new UsernamePasswordAuthenticationToken(username, null, authorities);
32                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
33                SecurityContextHolder.getContext().setAuthentication(authToken);
34            }
35        }
36
37        filterChain.doFilter(request, response);
38    }
39
40    private List<GrantedAuthority> buildAuthorities(String jwt) {
41        List<GrantedAuthority> authorities = new ArrayList<>();
42
43        List<String> roles = jwtService.extractRoles(jwt);
44        if (roles != null) {
45            roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
46        }
47
48        List<String> permissions = jwtService.extractPermissions(jwt);
49        if (permissions != null) {
50            permissions.forEach(perm -> authorities.add(new SimpleGrantedAuthority(perm)));
51        }
52
53        return authorities;
54    }
55}

Signing Algorithms: HMAC vs RSA vs ECDSA

The signing algorithm you choose determines how your JWTs are signed and verified. This decision has significant implications for security, performance, and system architecture.

PropertyHMAC (HS256)RSA (RS256)ECDSA (ES256)
TypeSymmetricAsymmetricAsymmetric
KeySingle shared secretPublic/Private key pairPublic/Private key pair
Key size256 bits2048+ bits256 bits
Signature size32 bytes256 bytes64 bytes
Sign speedFastSlowMedium
Verify speedFastFastSlow
Best forMonoliths, single serviceMicroservices, multi-tenantMobile, IoT, bandwidth-sensitive

When to Use Each Algorithm

  • HS256 (HMAC-SHA256): Use when a single server both signs and verifies tokens. Simple to set up, fast, and secure. The downside is that the same secret must exist on every service that needs to verify tokens.
  • RS256 (RSA-SHA256): Use in microservice architectures. The auth service holds the private key and signs tokens. All other services only need the public key to verify. If a microservice is compromised, the attacker can only verify tokens, not forge them.
  • ES256 (ECDSA-P256): Use when bandwidth matters (mobile APIs, IoT). Provides the same security as RS256 with much smaller key and signature sizes. Verification is slower than RSA.

RS256 Implementation with KeyPair

For production microservice architectures, RS256 is the standard choice. Here is how to implement it:

java
1@Service
2public class RsaJwtService {
3
4    private final RSAPrivateKey privateKey;
5    private final RSAPublicKey publicKey;
6
7    public RsaJwtService(@Value("${jwt.private-key-path}") String privateKeyPath,
8                          @Value("${jwt.public-key-path}") String publicKeyPath) throws Exception {
9        this.privateKey = loadPrivateKey(privateKeyPath);
10        this.publicKey = loadPublicKey(publicKeyPath);
11    }
12
13    public String generateToken(UserDetails userDetails) {
14        return Jwts.builder()
15                .subject(userDetails.getUsername())
16                .claim("roles", extractRoleNames(userDetails))
17                .issuedAt(new Date())
18                .expiration(new Date(System.currentTimeMillis() + 900_000)) // 15 min
19                .id(UUID.randomUUID().toString())
20                .signWith(privateKey, Jwts.SIG.RS256)
21                .compact();
22    }
23
24    public Claims extractAllClaims(String token) {
25        return Jwts.parser()
26                .verifyWith(publicKey)  // Only the public key is needed to verify
27                .build()
28                .parseSignedClaims(token)
29                .getPayload();
30    }
31
32    private RSAPrivateKey loadPrivateKey(String path) throws Exception {
33        String key = Files.readString(Path.of(path))
34                .replace("-----BEGIN PRIVATE KEY-----", "")
35                .replace("-----END PRIVATE KEY-----", "")
36                .replaceAll("\\s", "");
37        byte[] decoded = Base64.getDecoder().decode(key);
38        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
39        return (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(spec);
40    }
41
42    private RSAPublicKey loadPublicKey(String path) throws Exception {
43        String key = Files.readString(Path.of(path))
44                .replace("-----BEGIN PUBLIC KEY-----", "")
45                .replace("-----END PUBLIC KEY-----", "")
46                .replaceAll("\\s", "");
47        byte[] decoded = Base64.getDecoder().decode(key);
48        X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded);
49        return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec);
50    }
51}
Generate RSA keys for development: Run openssl genrsa -out private.pem 2048 then openssl rsa -in private.pem -pubout -out public.pem then openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.pem -out private-pkcs8.pem. Use the PKCS8 version with Java.

OWASP Security for JWT

The OWASP (Open Web Application Security Project) Top 10 provides a framework for securing web applications. Several of these categories apply directly to JWT implementations. Let us address each one.

A02: Cryptographic Failures — No Sensitive Data in Payload

The JWT payload is Base64-encoded, not encrypted. Anyone who intercepts a token can decode the payload instantly. Never include:

  • Passwords or password hashes
  • Social security numbers, national IDs, or tax IDs
  • Credit card numbers or financial account details
  • API keys or secrets
  • Full addresses or phone numbers

Only include what is strictly necessary for authorization: user ID, roles, permissions, and tenant context.

A07: Identification and Authentication Failures — Token Expiry

Access tokens should expire in 60 minutes or less. The recommended range is 5 to 15 minutes. Refresh tokens should expire in 7 to 30 days and must be stored in a database so they can be revoked.

Algorithm Confusion Attack

An attacker changes the JWT header from RS256 to HS256 and signs the token with the RSA public key (which is publicly available). If the server naively accepts both algorithms, it will verify the HMAC using the public key as the secret — and the forged token passes validation.

Mitigation: Always specify the expected algorithm explicitly when parsing JWTs. Never allow the token to dictate which algorithm to use. With JJWT 0.12, use verifyWith(key) which binds the algorithm to the key type — this prevents algorithm confusion by design.

The "none" Algorithm Attack

An attacker sets the algorithm to none and removes the signature. Some poorly implemented JWT libraries accept this as a valid unsigned token. JJWT 0.12 rejects none algorithms by default — but always verify your library handles this correctly.

Key Management

  • Never hardcode secrets in application.properties or source code
  • Use environment variables or a secrets manager (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault)
  • Rotate signing keys periodically (every 90 days for HMAC, annually for RSA)
  • Use different keys for different environments (dev, staging, production)
  • For RSA, store private keys in Hardware Security Modules (HSMs) in production

Rate Limiting Authentication Endpoints

Authentication endpoints are prime targets for brute-force attacks. An attacker can try thousands of username/password combinations per minute against /auth/login. Rate limiting restricts the number of requests a client can make within a time window.

Bucket4j Implementation

Bucket4j is a Java rate-limiting library based on the token bucket algorithm. Add the dependency to your pom.xml:

xml
1<dependency>
2    <groupId>com.bucket4j</groupId>
3    <artifactId>bucket4j-core</artifactId>
4    <version>8.10.1</version>
5</dependency>

Now create a rate-limiting filter for authentication endpoints:

java
1@Component
2public class AuthRateLimitFilter extends OncePerRequestFilter {
3
4    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
5
6    @Override
7    protected void doFilterInternal(HttpServletRequest request,
8                                    HttpServletResponse response,
9                                    FilterChain filterChain) throws ServletException, IOException {
10
11        String path = request.getRequestURI();
12
13        if (path.startsWith("/auth/login") || path.startsWith("/auth/refresh")) {
14            String clientIp = getClientIp(request);
15            Bucket bucket = buckets.computeIfAbsent(clientIp, this::createBucket);
16
17            if (bucket.tryConsume(1)) {
18                filterChain.doFilter(request, response);
19            } else {
20                response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
21                response.setContentType("application/json");
22                response.getWriter().write("""
23                    {"error": "Too many requests", "message": "Rate limit exceeded. Try again later."}
24                """);
25            }
26        } else {
27            filterChain.doFilter(request, response);
28        }
29    }
30
31    private Bucket createBucket(String key) {
32        return Bucket.builder()
33                .addLimit(BandwidthBuilder.builder()
34                    .capacity(5)                            // 5 requests
35                    .refillGreedy(5, Duration.ofMinutes(1)) // refill 5 per minute
36                    .build())
37                .addLimit(BandwidthBuilder.builder()
38                    .capacity(20)                           // 20 requests
39                    .refillGreedy(20, Duration.ofHours(1))  // refill 20 per hour
40                    .build())
41                .build();
42    }
43
44    private String getClientIp(HttpServletRequest request) {
45        String xForwardedFor = request.getHeader("X-Forwarded-For");
46        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
47            return xForwardedFor.split(",")[0].trim();
48        }
49        return request.getRemoteAddr();
50    }
51}
In-memory rate limiting does not scale horizontally. If you run multiple instances behind a load balancer, each instance has its own bucket map. For distributed rate limiting, use Bucket4j with Redis (bucket4j-redis) or a dedicated API gateway like Kong or Spring Cloud Gateway.

Register the Rate Limit Filter

Add the rate limit filter before the JWT filter in your security configuration:

java
1@Bean
2public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
3    return http
4        // ... existing configuration ...
5        .addFilterBefore(authRateLimitFilter, JwtAuthenticationFilter.class)
6        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
7        .build();
8}

Security Auditing and Logging

Every production authentication system needs comprehensive audit logging. You must be able to answer questions like: Who logged in at 3 AM? Which tokens were refreshed? Were there any failed login attempts before a successful one? Security auditing provides the answers.

SecurityAuditService Implementation

java
1@Service
2public class SecurityAuditService {
3
4    private static final Logger log = LoggerFactory.getLogger(SecurityAuditService.class);
5
6    public void logLoginSuccess(String username, String ipAddress, String userAgent) {
7        MDC.put("event", "AUTH_LOGIN_SUCCESS");
8        MDC.put("username", username);
9        MDC.put("ip", ipAddress);
10        MDC.put("userAgent", userAgent);
11
12        log.info("User '{}' logged in successfully from IP {}", username, ipAddress);
13
14        MDC.clear();
15    }
16
17    public void logLoginFailure(String username, String ipAddress, String reason) {
18        MDC.put("event", "AUTH_LOGIN_FAILURE");
19        MDC.put("username", username);
20        MDC.put("ip", ipAddress);
21        MDC.put("reason", reason);
22
23        log.warn("Failed login attempt for user '{}' from IP {}. Reason: {}",
24                username, ipAddress, reason);
25
26        MDC.clear();
27    }
28
29    public void logTokenRefresh(String username, String ipAddress) {
30        MDC.put("event", "AUTH_TOKEN_REFRESH");
31        MDC.put("username", username);
32        MDC.put("ip", ipAddress);
33
34        log.info("Token refreshed for user '{}' from IP {}", username, ipAddress);
35
36        MDC.clear();
37    }
38
39    public void logLogout(String username, String jti, String ipAddress) {
40        MDC.put("event", "AUTH_LOGOUT");
41        MDC.put("username", username);
42        MDC.put("jti", jti);
43        MDC.put("ip", ipAddress);
44
45        log.info("User '{}' logged out. Token JTI {} blacklisted.", username, jti);
46
47        MDC.clear();
48    }
49
50    public void logAccessDenied(String username, String resource, String ipAddress) {
51        MDC.put("event", "AUTH_ACCESS_DENIED");
52        MDC.put("username", username);
53        MDC.put("resource", resource);
54        MDC.put("ip", ipAddress);
55
56        log.warn("Access denied for user '{}' attempting to access '{}' from IP {}",
57                username, resource, ipAddress);
58
59        MDC.clear();
60    }
61
62    public void logSuspiciousActivity(String username, String detail, String ipAddress) {
63        MDC.put("event", "AUTH_SUSPICIOUS");
64        MDC.put("username", username);
65        MDC.put("detail", detail);
66        MDC.put("ip", ipAddress);
67
68        log.error("SUSPICIOUS ACTIVITY: User '{}' — {} — from IP {}",
69                username, detail, ipAddress);
70
71        MDC.clear();
72    }
73}

Using MDC with Logback

Configure Logback to output MDC fields as structured JSON. Add this to logback-spring.xml:

xml
1<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
2    <file>logs/security-audit.json</file>
3    <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
4        <fileNamePattern>logs/security-audit.%d{yyyy-MM-dd}.json</fileNamePattern>
5        <maxHistory>90</maxHistory>
6    </rollingPolicy>
7    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
8        <includeMdcKeyName>event</includeMdcKeyName>
9        <includeMdcKeyName>username</includeMdcKeyName>
10        <includeMdcKeyName>ip</includeMdcKeyName>
11        <includeMdcKeyName>reason</includeMdcKeyName>
12    </encoder>
13</appender>
14
15<logger name="com.example.security.SecurityAuditService" level="INFO" additivity="false">
16    <appender-ref ref="JSON_FILE" />
17</logger>
MDC pattern explained: MDC (Mapped Diagnostic Context) attaches key-value pairs to the current thread. When the log statement executes, these values are included in the log output. This is invaluable for filtering security events in ELK Stack, Splunk, or CloudWatch.

Integrating the Audit Service

Inject SecurityAuditService into your AuthController and call the appropriate method at each step:

java
1@RestController
2@RequestMapping("/auth")
3public class AuthController {
4
5    private final AuthenticationService authService;
6    private final SecurityAuditService auditService;
7
8    public AuthController(AuthenticationService authService,
9                          SecurityAuditService auditService) {
10        this.authService = authService;
11        this.auditService = auditService;
12    }
13
14    @PostMapping("/login")
15    public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request,
16                                               HttpServletRequest httpRequest) {
17        try {
18            AuthResponse response = authService.login(request);
19            auditService.logLoginSuccess(
20                request.getEmail(),
21                getClientIp(httpRequest),
22                httpRequest.getHeader("User-Agent")
23            );
24            return ResponseEntity.ok(response);
25        } catch (BadCredentialsException e) {
26            auditService.logLoginFailure(
27                request.getEmail(),
28                getClientIp(httpRequest),
29                "Invalid credentials"
30            );
31            throw e;
32        }
33    }
34
35    @PostMapping("/logout")
36    public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
37        String token = extractToken(httpRequest);
38        String username = jwtService.extractUsername(token);
39        String jti = jwtService.extractJti(token);
40
41        authService.logout(token);
42        auditService.logLogout(username, jti, getClientIp(httpRequest));
43
44        return ResponseEntity.noContent().build();
45    }
46}

Comprehensive JWT Security Testing

A production JWT system requires thorough testing. We need to verify that protected endpoints reject unauthorized access, that role-based rules are enforced, that expired and blacklisted tokens are rejected, and that our custom claims work correctly.

Test Setup

java
1@WebMvcTest(UserController.class)
2@Import(SecurityConfig.class)
3class UserControllerSecurityTest {
4
5    @Autowired
6    private MockMvc mockMvc;
7
8    @MockBean
9    private JwtService jwtService;
10
11    @MockBean
12    private UserService userService;
13
14    @MockBean
15    private UserDetailsService userDetailsService;
16
17    private String validAdminToken;
18    private String validUserToken;
19    private String expiredToken;
20
21    @BeforeEach
22    void setUp() {
23        validAdminToken = "valid-admin-token";
24        validUserToken = "valid-user-token";
25        expiredToken = "expired-token";
26
27        // Configure admin token
28        when(jwtService.extractUsername(validAdminToken)).thenReturn("[email protected]");
29        when(jwtService.isTokenValid(validAdminToken)).thenReturn(true);
30        when(jwtService.isTokenBlacklisted(validAdminToken)).thenReturn(false);
31        when(jwtService.extractRoles(validAdminToken)).thenReturn(List.of("ADMIN"));
32        when(jwtService.extractPermissions(validAdminToken))
33            .thenReturn(List.of("USER_CREATE", "USER_READ", "USER_DELETE"));
34
35        // Configure user token
36        when(jwtService.extractUsername(validUserToken)).thenReturn("[email protected]");
37        when(jwtService.isTokenValid(validUserToken)).thenReturn(true);
38        when(jwtService.isTokenBlacklisted(validUserToken)).thenReturn(false);
39        when(jwtService.extractRoles(validUserToken)).thenReturn(List.of("USER"));
40        when(jwtService.extractPermissions(validUserToken)).thenReturn(List.of("USER_READ"));
41
42        // Configure expired token
43        when(jwtService.extractUsername(expiredToken)).thenReturn("[email protected]");
44        when(jwtService.isTokenValid(expiredToken)).thenReturn(false);
45    }
46
47    @Test
48    void getAllUsers_withUserReadPermission_returns200() throws Exception {
49        when(userService.findAll()).thenReturn(List.of());
50
51        mockMvc.perform(get("/api/users")
52                .header("Authorization", "Bearer " + validUserToken))
53                .andExpect(status().isOk());
54    }
55
56    @Test
57    void createUser_withoutUserCreatePermission_returns403() throws Exception {
58        mockMvc.perform(post("/api/users")
59                .header("Authorization", "Bearer " + validUserToken)
60                .contentType(MediaType.APPLICATION_JSON)
61                .content("{\"email\":\"[email protected]\",\"password\":\"Str0ngP@ss!\"}"))
62                .andExpect(status().isForbidden());
63    }
64
65    @Test
66    void deleteUser_withAdminRoleAndPermission_returns204() throws Exception {
67        doNothing().when(userService).delete(1L);
68
69        mockMvc.perform(delete("/api/users/1")
70                .header("Authorization", "Bearer " + validAdminToken))
71                .andExpect(status().isNoContent());
72    }
73
74    @Test
75    void getAllUsers_withExpiredToken_returns401() throws Exception {
76        mockMvc.perform(get("/api/users")
77                .header("Authorization", "Bearer " + expiredToken))
78                .andExpect(status().isUnauthorized());
79    }
80
81    @Test
82    void getAllUsers_withBlacklistedToken_returns401() throws Exception {
83        String blacklistedToken = "blacklisted-token";
84        when(jwtService.extractUsername(blacklistedToken)).thenReturn("[email protected]");
85        when(jwtService.isTokenValid(blacklistedToken)).thenReturn(true);
86        when(jwtService.isTokenBlacklisted(blacklistedToken)).thenReturn(true);
87
88        mockMvc.perform(get("/api/users")
89                .header("Authorization", "Bearer " + blacklistedToken))
90                .andExpect(status().isUnauthorized());
91    }
92
93    @Test
94    void getAllUsers_withNoToken_returns401() throws Exception {
95        mockMvc.perform(get("/api/users"))
96                .andExpect(status().isUnauthorized());
97    }
98}

Custom @WithMockJwt Annotation

For cleaner tests, create a custom annotation that sets up the security context with JWT claims:

java
1@Retention(RetentionPolicy.RUNTIME)
2@Target(ElementType.METHOD)
3@WithSecurityContext(factory = MockJwtSecurityContextFactory.class)
4public @interface WithMockJwt {
5    String username() default "[email protected]";
6    String[] roles() default {"USER"};
7    String[] permissions() default {};
8    String tenantId() default "default";
9}
10
11public class MockJwtSecurityContextFactory
12        implements WithSecurityContextFactory<WithMockJwt> {
13
14    @Override
15    public SecurityContext createSecurityContext(WithMockJwt annotation) {
16        SecurityContext context = SecurityContextHolder.createEmptyContext();
17
18        List<GrantedAuthority> authorities = new ArrayList<>();
19        for (String role : annotation.roles()) {
20            authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
21        }
22        for (String permission : annotation.permissions()) {
23            authorities.add(new SimpleGrantedAuthority(permission));
24        }
25
26        UsernamePasswordAuthenticationToken auth =
27            new UsernamePasswordAuthenticationToken(
28                annotation.username(), null, authorities);
29
30        context.setAuthentication(auth);
31        return context;
32    }
33}
34
35// Usage in tests:
36@Test
37@WithMockJwt(username = "[email protected]", roles = {"ADMIN"}, permissions = {"USER_DELETE"})
38void deleteUser_asAdmin_returns204() throws Exception {
39    doNothing().when(userService).delete(1L);
40    mockMvc.perform(delete("/api/users/1"))
41            .andExpect(status().isNoContent());
42}
Testing philosophy: Test the behavior, not the implementation. Your tests should verify that an admin can delete users and a regular user cannot — not that a specific mock was called a specific number of times. This makes tests resilient to refactoring.

Environment-Specific Configuration

A production application needs different JWT configurations for each environment. Development might use HS256 with a simple secret, while production uses RS256 with keys from a vault.

application.yml — Shared Configuration

yaml
1spring:
2  application:
3    name: jwt-security-api
4
5jwt:
6  access-token-expiration: 900000   # 15 minutes (default)
7  refresh-token-expiration: 604800000  # 7 days (default)
8
9---
10# Development Profile
11spring:
12  config:
13    activate:
14      on-profile: dev
15
16jwt:
17  algorithm: HS256
18  secret: bXktc3VwZXItc2VjcmV0LWtleS1mb3ItZGV2ZWxvcG1lbnQtb25seS0yNTY=
19  access-token-expiration: 3600000   # 1 hour (longer for dev convenience)
20  refresh-token-expiration: 2592000000  # 30 days
21
22logging:
23  level:
24    com.example.security: DEBUG
25
26---
27# QA Profile
28spring:
29  config:
30    activate:
31      on-profile: qa
32
33jwt:
34  algorithm: RS256
35  private-key-path: /etc/secrets/jwt/private-key.pem
36  public-key-path: /etc/secrets/jwt/public-key.pem
37  access-token-expiration: 900000    # 15 minutes
38  refresh-token-expiration: 604800000  # 7 days
39
40logging:
41  level:
42    com.example.security: INFO
43
44---
45# Production Profile
46spring:
47  config:
48    activate:
49      on-profile: prod
50
51jwt:
52  algorithm: RS256
53  private-key-path: ${VAULT_JWT_PRIVATE_KEY_PATH}
54  public-key-path: ${VAULT_JWT_PUBLIC_KEY_PATH}
55  access-token-expiration: 600000    # 10 minutes (strictest)
56  refresh-token-expiration: 604800000  # 7 days
57
58logging:
59  level:
60    com.example.security: WARN
61    root: INFO
Never commit secrets to version control. The dev secret above is an example for local development only. In QA and production, keys should come from a secrets manager. Use ${ENVIRONMENT_VARIABLE} syntax in application.yml to reference environment variables or vault paths.

Dynamic Algorithm Selection

Create a configuration class that selects the appropriate JWT service based on the active profile:

java
1@Configuration
2public class JwtAlgorithmConfig {
3
4    @Bean
5    @Profile("dev")
6    public JwtService hmacJwtService(@Value("${jwt.secret}") String secret,
7                                     @Value("${jwt.access-token-expiration}") long expiration) {
8        return new HmacJwtService(secret, expiration);
9    }
10
11    @Bean
12    @Profile({"qa", "prod"})
13    public JwtService rsaJwtService(@Value("${jwt.private-key-path}") String privatePath,
14                                    @Value("${jwt.public-key-path}") String publicPath,
15                                    @Value("${jwt.access-token-expiration}") long expiration)
16                                    throws Exception {
17        return new RsaJwtService(privatePath, publicPath, expiration);
18    }
19}

Production Security Checklist

Before deploying your JWT authentication system to production, verify every item on this checklist. Each item addresses a specific attack vector or operational concern.

Algorithm and Keys

  • Use RS256 or ES256 in production (asymmetric). HS256 is acceptable only for single-service architectures.
  • RSA keys must be at least 2048 bits (4096 recommended).
  • Signing keys are stored in a secrets manager (Vault, AWS Secrets Manager), never in source code or environment files.
  • Key rotation policy is in place: every 90 days for symmetric keys, annually for asymmetric keys.
  • A key compromise response plan exists: revoke all tokens, rotate keys, notify affected users.

Token Configuration

  • Access token expiry is 15 minutes or less.
  • Refresh token expiry is 7 days or less for most applications.
  • Every token has a unique JTI (JWT ID) for blacklisting support.
  • Refresh tokens are stored in a database and can be revoked instantly.
  • Token rotation is enabled: issuing a new refresh token invalidates the previous one.

Transport and Storage

  • All endpoints use HTTPS (TLS 1.2+). No exceptions.
  • Access tokens are sent via Authorization: Bearer header.
  • Refresh tokens are stored in HttpOnly, Secure, SameSite=Strict cookies.
  • No tokens are stored in localStorage (vulnerable to XSS).

Headers and CORS

  • CORS is configured with explicit allowed origins — no wildcards (*) in production.
  • Security headers are set: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Strict-Transport-Security.
  • Content-Security-Policy is configured to prevent XSS and data injection.

Monitoring and Logging

  • All login attempts (success and failure) are logged with structured metadata (IP, user agent, timestamp).
  • Token refresh events are logged.
  • Access denied events are logged with the resource that was requested.
  • Alerts are configured for: 5+ failed logins from the same IP, login from a new geographic location, refresh token reuse (indicates theft).
  • Logs are shipped to a centralized system (ELK, Splunk, CloudWatch) and retained for at least 90 days.

Rate Limiting

  • /auth/login is rate-limited to 5 requests/minute per IP.
  • /auth/refresh is rate-limited to 10 requests/minute per IP.
  • Rate limiting works across all instances (distributed, not in-memory).
Key Compromise Response Plan: If you suspect your signing key has been compromised: (1) Immediately rotate to a new key pair. (2) Blacklist all existing JTIs in Redis. (3) Force all refresh tokens to be revoked in the database. (4) Notify affected users via email. (5) Investigate the breach and file a security incident report.

Course Conclusion

Congratulations — you have completed the JWT in Spring Boot 3 Course. Over four articles, we built a complete, production-ready JWT authentication and authorization system from scratch.

What We Built

Here is a summary of everything we covered across the entire course:

PartTopicKey Components
Part 1JWT FundamentalsJwtService, JwtAuthenticationFilter, SecurityConfig, AuthController, login/register flow
Part 2Refresh TokensRefreshToken entity, token rotation, reuse detection, HttpOnly cookies, /auth/refresh endpoint
Part 3JTI + Redis BlacklistingJTI claim, TokenBlacklistService, Redis integration, secure logout, /auth/logout endpoint
Part 4Production SecurityCustom claims, RBAC, @PreAuthorize, RS256, OWASP hardening, rate limiting, audit logging, testing

Architecture Overview

The final architecture follows a layered security model:

  1. Rate Limiting Layer — Bucket4j filter blocks brute-force attacks before they reach authentication logic.
  2. Authentication Layer — JwtAuthenticationFilter validates tokens, checks the Redis blacklist, and reconstructs authorities from JWT claims.
  3. Authorization Layer — @PreAuthorize annotations enforce role-based and permission-based access control at the method level.
  4. Audit Layer — SecurityAuditService logs every security event with structured metadata via MDC.
  5. Token Lifecycle Layer — Short-lived access tokens (15 min) + rotated refresh tokens (7 days) + instant revocation via JTI blacklisting.

Next Steps

Now that you have a solid JWT foundation, here are the natural next steps to further strengthen your authentication system:

  • MFA/TOTP: Add multi-factor authentication using Time-based One-Time Passwords (Google Authenticator, Authy). Use a library like dev.samstevens.totp to generate and verify TOTP codes.
  • OAuth2 / OpenID Connect: Integrate social logins (Google, GitHub) or become an OAuth2 provider yourself using Spring Authorization Server.
  • Microservices: Use RS256 keys with a centralized auth service that signs tokens and distribute the public key to all microservices for verification. Consider Spring Cloud Gateway as an API gateway with built-in token relay.
  • Passkeys / WebAuthn: The future of authentication is passwordless. Explore FIDO2/WebAuthn for a phishing-resistant login experience.
  • API Gateway Integration: Move JWT validation to the gateway layer (Kong, Spring Cloud Gateway) so your microservices only receive pre-validated requests.
Course Complete! Part 1 (JWT Fundamentals) ✓ → Part 2 (Refresh Tokens) ✓ → Part 3 (JTI + Redis Blacklisting) ✓ → Part 4 (Production Security) ✓

Thank you for following this entire course. The JWT authentication system you have built is not a toy project — it is a production-grade security layer that follows industry best practices. Use it as the foundation for your next Spring Boot application, and keep security at the forefront of every architectural decision you make.

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.