JWT in Spring Boot 3 Course #4: Custom Claims, RBAC and 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.
- 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
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:
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}
Decoded JWT Example
After adding custom claims, a decoded JWT payload looks like this:
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}
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:
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.
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:
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:
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('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:
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.
| Property | HMAC (HS256) | RSA (RS256) | ECDSA (ES256) |
|---|---|---|---|
| Type | Symmetric | Asymmetric | Asymmetric |
| Key | Single shared secret | Public/Private key pair | Public/Private key pair |
| Key size | 256 bits | 2048+ bits | 256 bits |
| Signature size | 32 bytes | 256 bytes | 64 bytes |
| Sign speed | Fast | Slow | Medium |
| Verify speed | Fast | Fast | Slow |
| Best for | Monoliths, single service | Microservices, multi-tenant | Mobile, 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:
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}
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.
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:
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:
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}
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:
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
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:
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>
Integrating the Audit Service
Inject SecurityAuditService into your AuthController and call the appropriate method at each step:
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
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:
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}
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
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
${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:
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-Policyis 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/loginis rate-limited to 5 requests/minute per IP./auth/refreshis rate-limited to 10 requests/minute per IP.- Rate limiting works across all instances (distributed, not in-memory).
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:
| Part | Topic | Key Components |
|---|---|---|
| Part 1 | JWT Fundamentals | JwtService, JwtAuthenticationFilter, SecurityConfig, AuthController, login/register flow |
| Part 2 | Refresh Tokens | RefreshToken entity, token rotation, reuse detection, HttpOnly cookies, /auth/refresh endpoint |
| Part 3 | JTI + Redis Blacklisting | JTI claim, TokenBlacklistService, Redis integration, secure logout, /auth/logout endpoint |
| Part 4 | Production Security | Custom claims, RBAC, @PreAuthorize, RS256, OWASP hardening, rate limiting, audit logging, testing |
Architecture Overview
The final architecture follows a layered security model:
- Rate Limiting Layer — Bucket4j filter blocks brute-force attacks before they reach authentication logic.
- Authentication Layer — JwtAuthenticationFilter validates tokens, checks the Redis blacklist, and reconstructs authorities from JWT claims.
- Authorization Layer — @PreAuthorize annotations enforce role-based and permission-based access control at the method level.
- Audit Layer — SecurityAuditService logs every security event with structured metadata via MDC.
- 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.totpto 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.
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.
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.