Cristhian Villegas
Cursos25 min read0 views

Curso JWT en Spring Boot 3 #4: Claims Personalizados, RBAC y Seguridad en Producción

Curso JWT en Spring Boot 3 #4: Claims Personalizados, RBAC y Seguridad en Producción

Parte 4 de 4 — Seguridad en Producción

Seguridad JWT en producción

Fuente: Adi Goldstein — Unsplash

Bienvenido a la Parte 4 y final del Curso JWT en Spring Boot 3. A lo largo de este curso hemos construido un sistema de autenticación completo paso a paso:

  • Parte 1: Fundamentos de JWT y configuración de Spring Security con filtro de autenticación
  • Parte 2: Refresh tokens con rotación segura y cookies HttpOnly
  • Parte 3: JTI, blacklisting con Redis y logout real
  • Parte 4 (este artículo): Claims personalizados, RBAC, algoritmos de firma, seguridad OWASP y hardening para producción

En este artículo final vamos a elevar nuestro sistema de autenticación al nivel que exige un entorno de producción real. No basta con generar y validar tokens: necesitamos control granular de permisos, algoritmos de firma robustos, protección contra ataques conocidos y auditoría completa.

Requisito previo: Este artículo asume que completaste las Partes 1, 2 y 3 del curso. El código base incluye: Spring Security con filtro JWT, refresh tokens con rotación, y blacklisting con Redis.

Al finalizar este artículo serás capaz de:

  • Agregar claims personalizados al JWT (roles, permisos, tenant)
  • Implementar RBAC completo con entidades Role y Permission
  • Usar @PreAuthorize con expresiones basadas en claims JWT
  • Elegir el algoritmo de firma correcto para tu arquitectura
  • Aplicar las recomendaciones de seguridad OWASP para tokens JWT
  • Proteger endpoints de autenticación con rate limiting
  • Implementar auditoría y logging de seguridad
  • Escribir tests completos para toda la capa de seguridad
  • Configurar perfiles por ambiente (dev, qa, prod)

Claims Personalizados en JWT

Hasta ahora nuestro JWT contiene claims estándar: sub (subject/email), iat (issued at), exp (expiration). Pero en una aplicación real necesitamos transportar información adicional para tomar decisiones de autorización sin consultar la base de datos en cada petición.

Los claims personalizados más comunes son:

  • roles: Lista de roles del usuario (ADMIN, USER, MODERATOR)
  • permissions: Lista de permisos específicos (USER_CREATE, REPORT_VIEW)
  • tenantId: Identificador del inquilino en aplicaciones multi-tenant
  • userId: ID interno del usuario
  • fullName: Nombre para mostrar en el frontend sin consulta extra
Nunca incluyas datos sensibles en el payload del JWT. El payload está codificado en Base64, no cifrado. Cualquiera puede decodificarlo. Nunca pongas: contraseñas, números de tarjeta, datos médicos, tokens de terceros, o PII sensible como CURP o RFC.

Veamos cómo agregar claims personalizados con JJWT 0.12:

java
1@Service
2@RequiredArgsConstructor
3public class JwtService {
4
5    @Value("${jwt.secret}")
6    private String secretKey;
7
8    @Value("${jwt.access-token-expiration}")
9    private long accessTokenExpiration;
10
11    public String generateToken(UserDetails userDetails, Map<String, Object> extraClaims) {
12        return Jwts.builder()
13                .subject(userDetails.getUsername())
14                .issuedAt(new Date())
15                .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
16                .claim("roles", extractRoles(userDetails))
17                .claim("permissions", extractPermissions(userDetails))
18                .claim("userId", ((CustomUserDetails) userDetails).getId())
19                .claim("tenantId", ((CustomUserDetails) userDetails).getTenantId())
20                .claims(extraClaims)
21                .signWith(getSigningKey(), Jwts.SIG.HS256)
22                .compact();
23    }
24
25    public <T> T extractClaim(String token, String claimName, Class<T> type) {
26        Claims claims = extractAllClaims(token);
27        return claims.get(claimName, type);
28    }
29
30    @SuppressWarnings("unchecked")
31    public List<String> extractRoles(String token) {
32        Claims claims = extractAllClaims(token);
33        return claims.get("roles", List.class);
34    }
35
36    @SuppressWarnings("unchecked")
37    public List<String> extractPermissions(String token) {
38        Claims claims = extractAllClaims(token);
39        return claims.get("permissions", List.class);
40    }
41
42    private List<String> extractRoles(UserDetails userDetails) {
43        return userDetails.getAuthorities().stream()
44                .map(GrantedAuthority::getAuthority)
45                .filter(a -> a.startsWith("ROLE_"))
46                .collect(Collectors.toList());
47    }
48
49    private List<String> extractPermissions(UserDetails userDetails) {
50        return userDetails.getAuthorities().stream()
51                .map(GrantedAuthority::getAuthority)
52                .filter(a -> !a.startsWith("ROLE_"))
53                .collect(Collectors.toList());
54    }
55
56    private Claims extractAllClaims(String token) {
57        return Jwts.parser()
58                .verifyWith(getSigningKey())
59                .build()
60                .parseSignedClaims(token)
61                .getPayload();
62    }
63
64    private SecretKey getSigningKey() {
65        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
66        return Keys.hmacShaKeyFor(keyBytes);
67    }
68}
Mantén el payload pequeño. Cada claim se incluye en el token que viaja en cada petición HTTP. Un JWT de 2 KB en cada request con 1000 peticiones/segundo son 2 MB/s de overhead. Incluye solo lo estrictamente necesario.

Implementando RBAC (Role-Based Access Control)

RBAC es el modelo de autorización más utilizado en aplicaciones empresariales. La idea es simple: los usuarios tienen roles, y los roles tienen permisos. El usuario hereda todos los permisos de sus roles.

Vamos a modelar las entidades JPA necesarias:

java
1@Entity
2@Table(name = "roles")
3@Getter @Setter @NoArgsConstructor
4public class Role {
5
6    @Id
7    @GeneratedValue(strategy = GenerationType.IDENTITY)
8    private Long id;
9
10    @Column(unique = true, nullable = false, length = 50)
11    private String name; // ADMIN, USER, MODERATOR
12
13    @Column(length = 200)
14    private String description;
15
16    @ManyToMany(fetch = FetchType.EAGER)
17    @JoinTable(
18        name = "role_permissions",
19        joinColumns = @JoinColumn(name = "role_id"),
20        inverseJoinColumns = @JoinColumn(name = "permission_id")
21    )
22    private Set<Permission> permissions = new HashSet<>();
23}
24
25@Entity
26@Table(name = "permissions")
27@Getter @Setter @NoArgsConstructor
28public class Permission {
29
30    @Id
31    @GeneratedValue(strategy = GenerationType.IDENTITY)
32    private Long id;
33
34    @Column(unique = true, nullable = false, length = 100)
35    private String name; // USER_CREATE, USER_READ, REPORT_EXPORT
36
37    @Column(length = 200)
38    private String description;
39}
40
41@Entity
42@Table(name = "users")
43@Getter @Setter @NoArgsConstructor
44public class User {
45
46    @Id
47    @GeneratedValue(strategy = GenerationType.IDENTITY)
48    private Long id;
49
50    @Column(unique = true, nullable = false)
51    private String email;
52
53    @Column(nullable = false)
54    private String password;
55
56    private String fullName;
57    private String tenantId;
58    private boolean enabled = true;
59
60    @ManyToMany(fetch = FetchType.EAGER)
61    @JoinTable(
62        name = "user_roles",
63        joinColumns = @JoinColumn(name = "user_id"),
64        inverseJoinColumns = @JoinColumn(name = "role_id")
65    )
66    private Set<Role> roles = new HashSet<>();
67
68    // Método para obtener TODOS los permisos (de todos los roles)
69    public Set<String> getAllPermissions() {
70        return roles.stream()
71                .flatMap(role -> role.getPermissions().stream())
72                .map(Permission::getName)
73                .collect(Collectors.toSet());
74    }
75
76    // Método para obtener los nombres de roles
77    public Set<String> getRoleNames() {
78        return roles.stream()
79                .map(role -> "ROLE_" + role.getName())
80                .collect(Collectors.toSet());
81    }
82}

Ahora necesitamos un UserDetailsService que cargue las authorities desde la base de datos e incluya tanto roles como permisos:

java
1@Service
2@RequiredArgsConstructor
3public class CustomUserDetailsService implements UserDetailsService {
4
5    private final UserRepository userRepository;
6
7    @Override
8    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
9        User user = userRepository.findByEmail(email)
10                .orElseThrow(() -> new UsernameNotFoundException(
11                        "Usuario no encontrado: " + email));
12
13        // Combinar roles y permisos como GrantedAuthority
14        Set<GrantedAuthority> authorities = new HashSet<>();
15
16        // Agregar roles con prefijo ROLE_
17        user.getRoleNames().forEach(role ->
18                authorities.add(new SimpleGrantedAuthority(role)));
19
20        // Agregar permisos individuales
21        user.getAllPermissions().forEach(permission ->
22                authorities.add(new SimpleGrantedAuthority(permission)));
23
24        return new CustomUserDetails(
25                user.getId(),
26                user.getEmail(),
27                user.getPassword(),
28                user.getFullName(),
29                user.getTenantId(),
30                user.isEnabled(),
31                authorities
32        );
33    }
34}
¿Por qué EAGER fetch? Los roles y permisos se cargan con FetchType.EAGER porque siempre los necesitamos al autenticar al usuario. Si tu tabla de permisos es muy grande, considera usar una caché (Caffeine o Redis) para evitar joins costosos en cada login.

@PreAuthorize con Claims JWT

Spring Security permite proteger métodos individuales con @PreAuthorize. Esto es mucho más granular que proteger rutas completas en SecurityFilterChain.

Primero, habilitamos la seguridad a nivel de método en nuestra configuración:

java
1@Configuration
2@EnableWebSecurity
3@EnableMethodSecurity(prePostEnabled = true)
4@RequiredArgsConstructor
5public class SecurityConfig {
6
7    private final JwtAuthenticationFilter jwtAuthFilter;
8    private final CustomUserDetailsService userDetailsService;
9
10    @Bean
11    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
12        http
13            .csrf(AbstractHttpConfigurer::disable)
14            .sessionManagement(session ->
15                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
16            .authorizeHttpRequests(auth -> auth
17                .requestMatchers("/auth/**").permitAll()
18                .requestMatchers("/actuator/health").permitAll()
19                .anyRequest().authenticated()
20            )
21            .authenticationProvider(authenticationProvider())
22            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
23
24        return http.build();
25    }
26
27    @Bean
28    public AuthenticationProvider authenticationProvider() {
29        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
30        provider.setUserDetailsService(userDetailsService);
31        provider.setPasswordEncoder(passwordEncoder());
32        return provider;
33    }
34
35    @Bean
36    public PasswordEncoder passwordEncoder() {
37        return new BCryptPasswordEncoder();
38    }
39}

Ahora podemos usar @PreAuthorize en nuestros controllers:

java
1@RestController
2@RequestMapping("/api/users")
3@RequiredArgsConstructor
4public class UserController {
5
6    private final UserService userService;
7
8    @GetMapping
9    @PreAuthorize("hasAuthority('USER_READ')")
10    public ResponseEntity<List<UserDto>> getAllUsers() {
11        return ResponseEntity.ok(userService.findAll());
12    }
13
14    @PostMapping
15    @PreAuthorize("hasAuthority('USER_CREATE')")
16    public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserRequest request) {
17        return ResponseEntity.status(HttpStatus.CREATED)
18                .body(userService.create(request));
19    }
20
21    @DeleteMapping("/{id}")
22    @PreAuthorize("hasRole('ADMIN')")
23    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
24        userService.delete(id);
25        return ResponseEntity.noContent().build();
26    }
27
28    @PutMapping("/{id}")
29    @PreAuthorize("hasAuthority('USER_UPDATE') or #id == authentication.principal.id")
30    public ResponseEntity<UserDto> updateUser(
31            @PathVariable Long id,
32            @Valid @RequestBody UpdateUserRequest request) {
33        return ResponseEntity.ok(userService.update(id, request));
34    }
35}
36
37@RestController
38@RequestMapping("/api/reports")
39@RequiredArgsConstructor
40public class ReportController {
41
42    private final ReportService reportService;
43
44    @GetMapping
45    @PreAuthorize("hasAnyAuthority('REPORT_VIEW', 'REPORT_EXPORT')")
46    public ResponseEntity<List<ReportDto>> listReports() {
47        return ResponseEntity.ok(reportService.findAll());
48    }
49
50    @PostMapping("/export")
51    @PreAuthorize("hasAuthority('REPORT_EXPORT') and hasRole('ADMIN')")
52    public ResponseEntity<byte[]> exportReport(@RequestBody ExportRequest request) {
53        return ResponseEntity.ok(reportService.export(request));
54    }
55}
Cuidado con hasRole vs hasAuthority: Spring Security agrega automáticamente el prefijo ROLE_ cuando usas hasRole('ADMIN'), por lo que busca la authority ROLE_ADMIN. En cambio, hasAuthority('USER_CREATE') busca el string exacto. Asegúrate de que tus roles tengan el prefijo al construir las authorities.

También necesitamos actualizar nuestro JwtAuthenticationFilter para reconstruir las authorities desde los claims del JWT:

java
1@Component
2@RequiredArgsConstructor
3public class JwtAuthenticationFilter extends OncePerRequestFilter {
4
5    private final JwtService jwtService;
6
7    @Override
8    protected void doFilterInternal(
9            HttpServletRequest request,
10            HttpServletResponse response,
11            FilterChain filterChain) throws ServletException, IOException {
12
13        String authHeader = request.getHeader("Authorization");
14        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
15            filterChain.doFilter(request, response);
16            return;
17        }
18
19        String jwt = authHeader.substring(7);
20        String username = jwtService.extractUsername(jwt);
21
22        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
23            if (jwtService.isTokenValid(jwt)) {
24                // Reconstruir authorities desde los claims del token
25                List<String> roles = jwtService.extractRoles(jwt);
26                List<String> permissions = jwtService.extractPermissions(jwt);
27
28                List<GrantedAuthority> authorities = new ArrayList<>();
29                roles.forEach(r -> authorities.add(new SimpleGrantedAuthority(r)));
30                permissions.forEach(p -> authorities.add(new SimpleGrantedAuthority(p)));
31
32                UsernamePasswordAuthenticationToken authToken =
33                        new UsernamePasswordAuthenticationToken(username, null, authorities);
34                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
35
36                SecurityContextHolder.getContext().setAuthentication(authToken);
37            }
38        }
39
40        filterChain.doFilter(request, response);
41    }
42}

Algoritmos de Firma: HMAC vs RSA vs ECDSA

El algoritmo de firma es una de las decisiones más importantes al implementar JWT en producción. Define cómo se garantiza la integridad y autenticidad del token.

Característica HMAC (HS256) RSA (RS256) ECDSA (ES256)
Tipo Simétrico Asimétrico Asimétrico
Claves Una clave compartida Par público/privado Par público/privado
Tamaño de firma 32 bytes 256 bytes 64 bytes
Rendimiento firma Muy rápido Lento Moderado
Rendimiento verificación Muy rápido Rápido Moderado
Caso de uso ideal Monolito Microservicios IoT, móviles
Tamaño de clave 256 bits 2048-4096 bits 256 bits

¿Cuándo usar cada uno?

  • HS256: Perfecto para monolitos donde el mismo servicio genera y valida los tokens. Simple de configurar y muy rápido.
  • RS256: Ideal para microservicios. El servicio de autenticación firma con la clave privada, y los demás servicios validan con la clave pública. Ningún servicio necesita conocer la clave privada.
  • ES256: Cuando necesitas firmas más pequeñas (IoT, aplicaciones móviles con ancho de banda limitado) con la seguridad de un algoritmo asimétrico.

Veamos cómo implementar RS256 con un par de claves RSA:

java
1@Configuration
2public class RsaKeyConfig {
3
4    @Value("${jwt.rsa.private-key-path}")
5    private String privateKeyPath;
6
7    @Value("${jwt.rsa.public-key-path}")
8    private String publicKeyPath;
9
10    @Bean
11    public RSAPrivateKey rsaPrivateKey() throws Exception {
12        String key = Files.readString(Path.of(privateKeyPath))
13                .replace("-----BEGIN PRIVATE KEY-----", "")
14                .replace("-----END PRIVATE KEY-----", "")
15                .replaceAll("\\s", "");
16
17        byte[] keyBytes = Base64.getDecoder().decode(key);
18        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
19        return (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(spec);
20    }
21
22    @Bean
23    public RSAPublicKey rsaPublicKey() throws Exception {
24        String key = Files.readString(Path.of(publicKeyPath))
25                .replace("-----BEGIN PUBLIC KEY-----", "")
26                .replace("-----END PUBLIC KEY-----", "")
27                .replaceAll("\\s", "");
28
29        byte[] keyBytes = Base64.getDecoder().decode(key);
30        X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
31        return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec);
32    }
33}
34
35@Service
36@RequiredArgsConstructor
37public class RsaJwtService {
38
39    private final RSAPrivateKey privateKey;
40    private final RSAPublicKey publicKey;
41
42    @Value("${jwt.access-token-expiration}")
43    private long accessTokenExpiration;
44
45    public String generateToken(UserDetails userDetails) {
46        return Jwts.builder()
47                .subject(userDetails.getUsername())
48                .issuedAt(new Date())
49                .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
50                .claim("roles", extractRoles(userDetails))
51                .claim("permissions", extractPermissions(userDetails))
52                .signWith(privateKey, Jwts.SIG.RS256) // Firma con clave privada
53                .compact();
54    }
55
56    public Claims extractAllClaims(String token) {
57        return Jwts.parser()
58                .verifyWith(publicKey) // Verifica con clave pública
59                .build()
60                .parseSignedClaims(token)
61                .getPayload();
62    }
63
64    // ... demás métodos de extracción igual que antes
65}

Para generar las claves RSA, ejecuta estos comandos en tu terminal:

bash
1# Generar clave privada RSA de 2048 bits
2openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
3
4# Extraer la clave pública
5openssl rsa -pubout -in private_key.pem -out public_key.pem
6
7# Convertir clave privada a formato PKCS8 (requerido por Java)
8openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private_key.pem -out private_key_pkcs8.pem
Para microservicios: Distribuye solo public_key.pem a los servicios que necesitan validar tokens. La clave privada solo debe existir en el servicio de autenticación, idealmente almacenada en un vault (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault).

Seguridad OWASP para JWT

El OWASP (Open Web Application Security Project) proporciona guías esenciales para proteger aplicaciones web. Veamos las vulnerabilidades más relevantes para JWT y cómo prevenirlas.

A02: Fallas Criptográficas — No incluyas datos sensibles en el payload

El payload de un JWT es visible para cualquiera que intercepte el token. Basta con decodificarlo en Base64:

bash
1# Cualquiera puede leer el payload
2echo "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZXMiOlsiUk9MRV9VU0VSIl19.xxx" \
3  | cut -d'.' -f2 | base64 -d

Regla simple: si no te sentirías cómodo publicando esa información en un tweet, no la pongas en el JWT.

A07: Expiración corta — Máximo 60 minutos para access tokens

Cuanto más corta sea la vida del access token, menor es la ventana de ataque si es comprometido. Recomendación:

  • Access token: 15 minutos (ideal) a 60 minutos (máximo)
  • Refresh token: 7 días (ideal) a 30 días (máximo)

Ataque de confusión de algoritmo (Algorithm Confusion)

Este ataque ocurre cuando el servidor acepta el algoritmo especificado en el header del JWT sin validarlo. Un atacante puede cambiar el header de RS256 a HS256 y usar la clave pública RSA como secreto HMAC.

java
1// VULNERABLE — acepta cualquier algoritmo del token
2Jwts.parser()
3    .setSigningKey(publicKey)
4    .build()
5    .parseClaimsJws(token);
6
7// SEGURO — fuerza un algoritmo específico
8Jwts.parser()
9    .verifyWith(publicKey)          // JJWT 0.12 fuerza la verificación tipada
10    .requireAlgorithm("RS256")      // Rechaza cualquier otro algoritmo
11    .build()
12    .parseSignedClaims(token);
Ataque "none" algorithm: Nunca aceptes tokens con "alg": "none". JJWT 0.12 lo rechaza por defecto, pero si usas otra biblioteca, verifica que esté bloqueado explícitamente. Este ataque permite a un atacante crear tokens válidos sin ninguna firma.

Buenas prácticas de gestión de claves

  • Nunca hardcodees las claves en el código fuente
  • Usa variables de entorno o un vault de secretos
  • Rota las claves periódicamente (cada 90 días como mínimo)
  • Mantén un período de gracia durante la rotación donde ambas claves son válidas
  • Las claves HMAC deben tener al menos 256 bits de entropía
  • Las claves RSA deben ser de al menos 2048 bits (preferiblemente 4096)

Rate Limiting en Endpoints de Autenticación

Los endpoints de login y refresh son los objetivos principales de ataques de fuerza bruta. Sin rate limiting, un atacante puede probar miles de contraseñas por segundo.

Vamos a implementar rate limiting con Bucket4j y Spring Boot:

xml
1<!-- pom.xml -->
2<dependency>
3    <groupId>com.bucket4j</groupId>
4    <artifactId>bucket4j-core</artifactId>
5    <version>8.7.0</version>
6</dependency>
java
1@Component
2public class RateLimitFilter extends OncePerRequestFilter {
3
4    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
5
6    private static final long LOGIN_CAPACITY = 5;           // 5 intentos
7    private static final long LOGIN_REFILL_MINUTES = 15;    // cada 15 minutos
8    private static final long REFRESH_CAPACITY = 10;        // 10 refreshes
9    private static final long REFRESH_REFILL_MINUTES = 5;   // cada 5 minutos
10
11    @Override
12    protected void doFilterInternal(
13            HttpServletRequest request,
14            HttpServletResponse response,
15            FilterChain filterChain) throws ServletException, IOException {
16
17        String path = request.getRequestURI();
18
19        if ("/auth/login".equals(path) && "POST".equals(request.getMethod())) {
20            String clientIp = getClientIp(request);
21            Bucket bucket = buckets.computeIfAbsent(
22                    "login:" + clientIp, k -> createLoginBucket());
23
24            if (!bucket.tryConsume(1)) {
25                response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
26                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
27                response.getWriter().write(
28                        "{"error":"Demasiados intentos de inicio de sesión. "
29                        + "Intenta de nuevo en 15 minutos."}");
30                return;
31            }
32        }
33
34        if ("/auth/refresh".equals(path) && "POST".equals(request.getMethod())) {
35            String clientIp = getClientIp(request);
36            Bucket bucket = buckets.computeIfAbsent(
37                    "refresh:" + clientIp, k -> createRefreshBucket());
38
39            if (!bucket.tryConsume(1)) {
40                response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
41                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
42                response.getWriter().write(
43                        "{"error":"Demasiadas solicitudes de refresh. "
44                        + "Intenta de nuevo en 5 minutos."}");
45                return;
46            }
47        }
48
49        filterChain.doFilter(request, response);
50    }
51
52    private Bucket createLoginBucket() {
53        return Bucket.builder()
54                .addLimit(Bandwidth.classic(
55                        LOGIN_CAPACITY,
56                        Refill.intervally(LOGIN_CAPACITY,
57                                Duration.ofMinutes(LOGIN_REFILL_MINUTES))))
58                .build();
59    }
60
61    private Bucket createRefreshBucket() {
62        return Bucket.builder()
63                .addLimit(Bandwidth.classic(
64                        REFRESH_CAPACITY,
65                        Refill.intervally(REFRESH_CAPACITY,
66                                Duration.ofMinutes(REFRESH_REFILL_MINUTES))))
67                .build();
68    }
69
70    private String getClientIp(HttpServletRequest request) {
71        String xForwardedFor = request.getHeader("X-Forwarded-For");
72        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
73            return xForwardedFor.split(",")[0].trim();
74        }
75        return request.getRemoteAddr();
76    }
77
78    @Override
79    protected boolean shouldNotFilter(HttpServletRequest request) {
80        String path = request.getRequestURI();
81        return !path.startsWith("/auth/");
82    }
83}
Rate limiting en memoria vs distribuido: La implementación anterior almacena los buckets en memoria, lo cual funciona para un solo servidor. En producción con múltiples instancias, necesitas un backend distribuido como Redis. Bucket4j soporta Redis, Hazelcast y otras opciones a través de bucket4j-redis.

Auditoría y Logging de Seguridad

En producción necesitas saber quién hizo qué, cuándo y desde dónde. Un buen sistema de auditoría te permite detectar actividades sospechosas y responder a incidentes de seguridad.

Implementemos un servicio de auditoría que use MDC (Mapped Diagnostic Context) para correlacionar todos los logs de una misma petición:

java
1@Service
2@Slf4j
3public class SecurityAuditService {
4
5    public void logLoginSuccess(String email, String ipAddress, String userAgent) {
6        MDC.put("action", "LOGIN_SUCCESS");
7        MDC.put("userEmail", email);
8        MDC.put("ipAddress", ipAddress);
9        MDC.put("userAgent", userAgent);
10        MDC.put("transactionId", UUID.randomUUID().toString());
11
12        log.info("Inicio de sesión exitoso para el usuario: {}", email);
13
14        MDC.clear();
15    }
16
17    public void logLoginFailure(String email, String ipAddress, String reason) {
18        MDC.put("action", "LOGIN_FAILURE");
19        MDC.put("userEmail", email);
20        MDC.put("ipAddress", ipAddress);
21        MDC.put("reason", reason);
22        MDC.put("transactionId", UUID.randomUUID().toString());
23
24        log.warn("Intento de inicio de sesión fallido para: {} — Razón: {}", email, reason);
25
26        MDC.clear();
27    }
28
29    public void logTokenRefresh(String email, String ipAddress) {
30        MDC.put("action", "TOKEN_REFRESH");
31        MDC.put("userEmail", email);
32        MDC.put("ipAddress", ipAddress);
33        MDC.put("transactionId", UUID.randomUUID().toString());
34
35        log.info("Token renovado para el usuario: {}", email);
36
37        MDC.clear();
38    }
39
40    public void logLogout(String email, String ipAddress) {
41        MDC.put("action", "LOGOUT");
42        MDC.put("userEmail", email);
43        MDC.put("ipAddress", ipAddress);
44        MDC.put("transactionId", UUID.randomUUID().toString());
45
46        log.info("Cierre de sesión para el usuario: {}", email);
47
48        MDC.clear();
49    }
50
51    public void logAccessDenied(String email, String resource, String requiredPermission) {
52        MDC.put("action", "ACCESS_DENIED");
53        MDC.put("userEmail", email);
54        MDC.put("resource", resource);
55        MDC.put("requiredPermission", requiredPermission);
56        MDC.put("transactionId", UUID.randomUUID().toString());
57
58        log.warn("Acceso denegado — Usuario: {}, Recurso: {}, Permiso requerido: {}",
59                email, resource, requiredPermission);
60
61        MDC.clear();
62    }
63
64    public void logSuspiciousActivity(String email, String ipAddress, String detail) {
65        MDC.put("action", "SUSPICIOUS_ACTIVITY");
66        MDC.put("userEmail", email);
67        MDC.put("ipAddress", ipAddress);
68        MDC.put("transactionId", UUID.randomUUID().toString());
69
70        log.error("Actividad sospechosa detectada — Usuario: {}, IP: {}, Detalle: {}",
71                email, ipAddress, detail);
72
73        MDC.clear();
74    }
75}

Configura Logback para incluir los campos MDC en formato JSON (ideal para herramientas como ELK Stack o Grafana Loki):

xml
1<!-- logback-spring.xml -->
2<configuration>
3    <appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
4        <file>logs/security-audit.log</file>
5        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
6            <fileNamePattern>logs/security-audit.%d{yyyy-MM-dd}.log</fileNamePattern>
7            <maxHistory>90</maxHistory>
8        </rollingPolicy>
9        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
10            <includeMdcKeyName>action</includeMdcKeyName>
11            <includeMdcKeyName>userEmail</includeMdcKeyName>
12            <includeMdcKeyName>ipAddress</includeMdcKeyName>
13            <includeMdcKeyName>transactionId</includeMdcKeyName>
14            <includeMdcKeyName>resource</includeMdcKeyName>
15            <includeMdcKeyName>reason</includeMdcKeyName>
16        </encoder>
17    </appender>
18
19    <logger name="com.tuapp.security" level="INFO" additivity="false">
20        <appender-ref ref="JSON_FILE" />
21    </logger>
22</configuration>
Patrón MDC con transactionId: El transactionId permite correlacionar todos los logs de una misma operación. En una arquitectura de microservicios, propaga este ID a través de headers HTTP (X-Transaction-Id) para tener trazabilidad end-to-end.

Testing Completo de Seguridad JWT

Un sistema de seguridad sin tests es una bomba de tiempo. Vamos a escribir tests que cubran los escenarios más críticos usando JUnit 5 y MockMvc.

java
1@WebMvcTest(UserController.class)
2@Import({SecurityConfig.class, JwtService.class})
3class UserControllerSecurityTest {
4
5    @Autowired
6    private MockMvc mockMvc;
7
8    @MockBean
9    private UserService userService;
10
11    @MockBean
12    private CustomUserDetailsService userDetailsService;
13
14    @Autowired
15    private JwtService jwtService;
16
17    private String adminToken;
18    private String userToken;
19
20    @BeforeEach
21    void setUp() {
22        // Crear token con rol ADMIN y permiso USER_CREATE
23        List<GrantedAuthority> adminAuthorities = List.of(
24                new SimpleGrantedAuthority("ROLE_ADMIN"),
25                new SimpleGrantedAuthority("USER_CREATE"),
26                new SimpleGrantedAuthority("USER_READ"),
27                new SimpleGrantedAuthority("USER_UPDATE")
28        );
29        UserDetails adminDetails = new User("[email protected]", "password", adminAuthorities);
30        adminToken = jwtService.generateToken(adminDetails, Map.of());
31
32        // Crear token con rol USER solamente
33        List<GrantedAuthority> userAuthorities = List.of(
34                new SimpleGrantedAuthority("ROLE_USER"),
35                new SimpleGrantedAuthority("USER_READ")
36        );
37        UserDetails userDetails = new User("[email protected]", "password", userAuthorities);
38        userToken = jwtService.generateToken(userDetails, Map.of());
39    }
40
41    @Test
42    void getAllUsers_conPermisoUserRead_retorna200() throws Exception {
43        // Arrange
44        when(userService.findAll()).thenReturn(List.of());
45
46        // Act & Assert
47        mockMvc.perform(get("/api/users")
48                        .header("Authorization", "Bearer " + userToken))
49                .andExpect(status().isOk());
50    }
51
52    @Test
53    void createUser_sinPermisoUserCreate_retorna403() throws Exception {
54        // Act & Assert (userToken no tiene USER_CREATE)
55        mockMvc.perform(post("/api/users")
56                        .header("Authorization", "Bearer " + userToken)
57                        .contentType(MediaType.APPLICATION_JSON)
58                        .content("{\"email\":\"[email protected]\",\"password\":\"Pass1234!\"}"))
59                .andExpect(status().isForbidden());
60    }
61
62    @Test
63    void createUser_conPermisoUserCreate_retorna201() throws Exception {
64        // Arrange
65        UserDto dto = new UserDto(1L, "[email protected]", "Nuevo Usuario");
66        when(userService.create(any())).thenReturn(dto);
67
68        // Act & Assert
69        mockMvc.perform(post("/api/users")
70                        .header("Authorization", "Bearer " + adminToken)
71                        .contentType(MediaType.APPLICATION_JSON)
72                        .content("{\"email\":\"[email protected]\",\"password\":\"Pass1234!\"}"))
73                .andExpect(status().isCreated());
74    }
75
76    @Test
77    void deleteUser_sinRolAdmin_retorna403() throws Exception {
78        // Act & Assert
79        mockMvc.perform(delete("/api/users/1")
80                        .header("Authorization", "Bearer " + userToken))
81                .andExpect(status().isForbidden());
82    }
83
84    @Test
85    void deleteUser_conRolAdmin_retorna204() throws Exception {
86        // Arrange
87        doNothing().when(userService).delete(1L);
88
89        // Act & Assert
90        mockMvc.perform(delete("/api/users/1")
91                        .header("Authorization", "Bearer " + adminToken))
92                .andExpect(status().isNoContent());
93    }
94
95    @Test
96    void cualquierEndpoint_sinToken_retorna401() throws Exception {
97        mockMvc.perform(get("/api/users"))
98                .andExpect(status().isUnauthorized());
99    }
100
101    @Test
102    void cualquierEndpoint_conTokenExpirado_retorna401() throws Exception {
103        // Arrange — generar token que ya expiró
104        String expiredToken = Jwts.builder()
105                .subject("[email protected]")
106                .issuedAt(new Date(System.currentTimeMillis() - 3600000))
107                .expiration(new Date(System.currentTimeMillis() - 1800000))
108                .signWith(jwtService.getSigningKey(), Jwts.SIG.HS256)
109                .compact();
110
111        // Act & Assert
112        mockMvc.perform(get("/api/users")
113                        .header("Authorization", "Bearer " + expiredToken))
114                .andExpect(status().isUnauthorized());
115    }
116
117    @Test
118    void cualquierEndpoint_conTokenManipulado_retorna401() throws Exception {
119        // Arrange — alterar el payload del token
120        String manipulatedToken = adminToken.substring(0, adminToken.lastIndexOf('.'))
121                + ".invalidSignature";
122
123        // Act & Assert
124        mockMvc.perform(get("/api/users")
125                        .header("Authorization", "Bearer " + manipulatedToken))
126                .andExpect(status().isUnauthorized());
127    }
128}

También puedes crear una anotación personalizada para simplificar los tests:

java
1@Retention(RetentionPolicy.RUNTIME)
2@WithSecurityContext(factory = WithMockJwtSecurityContextFactory.class)
3public @interface WithMockJwt {
4    String email() default "[email protected]";
5    String[] roles() default {"USER"};
6    String[] permissions() default {};
7}
8
9public class WithMockJwtSecurityContextFactory
10        implements WithSecurityContextFactory<WithMockJwt> {
11
12    @Override
13    public SecurityContext createSecurityContext(WithMockJwt annotation) {
14        SecurityContext context = SecurityContextHolder.createEmptyContext();
15
16        List<GrantedAuthority> authorities = new ArrayList<>();
17        for (String role : annotation.roles()) {
18            authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
19        }
20        for (String permission : annotation.permissions()) {
21            authorities.add(new SimpleGrantedAuthority(permission));
22        }
23
24        UsernamePasswordAuthenticationToken auth =
25                new UsernamePasswordAuthenticationToken(
26                        annotation.email(), null, authorities);
27        context.setAuthentication(auth);
28        return context;
29    }
30}
31
32// Uso en tests:
33@Test
34@WithMockJwt(email = "[email protected]", roles = {"ADMIN"}, permissions = {"USER_CREATE"})
35void createUser_conAnotacionPersonalizada_retorna201() throws Exception {
36    when(userService.create(any())).thenReturn(new UserDto(1L, "[email protected]", "Test"));
37
38    mockMvc.perform(post("/api/users")
39                    .contentType(MediaType.APPLICATION_JSON)
40                    .content("{\"email\":\"[email protected]\",\"password\":\"Pass1234!\"}"))
41            .andExpect(status().isCreated());
42}
Cobertura mínima recomendada: Para la capa de seguridad, apunta a un 95% de cobertura en services y 90% en controllers. Los tests de seguridad deben cubrir: autenticación exitosa, autenticación fallida, autorización por rol, autorización por permiso, tokens expirados, tokens manipulados y tokens en blacklist.

Configuración por Ambiente

Cada ambiente tiene necesidades diferentes de seguridad. En desarrollo quieres velocidad y conveniencia; en producción quieres máxima seguridad.

yaml
1# application.yml — configuración base
2spring:
3  application:
4    name: jwt-security-app
5
6jwt:
7  algorithm: HS256
8  access-token-expiration: 900000   # 15 minutos
9  refresh-token-expiration: 604800000  # 7 días
10
11---
12# application-dev.yml
13spring:
14  config:
15    activate:
16      on-profile: dev
17
18jwt:
19  algorithm: HS256
20  secret: ZGV2LXNlY3JldC1rZXktMjU2LWJpdHMtbWluaW11bS1sZW5ndGg=
21  access-token-expiration: 3600000   # 1 hora (más largo para desarrollo)
22  refresh-token-expiration: 2592000000  # 30 días
23
24logging:
25  level:
26    com.tuapp.security: DEBUG
27
28---
29# application-qa.yml
30spring:
31  config:
32    activate:
33      on-profile: qa
34
35jwt:
36  algorithm: HS256
37  secret: ${JWT_SECRET}  # Variable de entorno, no hardcodeada
38  access-token-expiration: 900000   # 15 minutos
39  refresh-token-expiration: 604800000  # 7 días
40
41logging:
42  level:
43    com.tuapp.security: INFO
44
45---
46# application-prod.yml
47spring:
48  config:
49    activate:
50      on-profile: prod
51
52jwt:
53  algorithm: RS256
54  rsa:
55    private-key-path: ${JWT_RSA_PRIVATE_KEY_PATH}  # Vault / Secrets Manager
56    public-key-path: ${JWT_RSA_PUBLIC_KEY_PATH}
57  access-token-expiration: 900000   # 15 minutos (estricto)
58  refresh-token-expiration: 604800000  # 7 días
59
60server:
61  ssl:
62    enabled: true
63    key-store: ${SSL_KEYSTORE_PATH}
64    key-store-password: ${SSL_KEYSTORE_PASSWORD}
65    key-store-type: PKCS12
66
67logging:
68  level:
69    com.tuapp.security: WARN
70    root: INFO

Para seleccionar el servicio JWT correcto según el algoritmo configurado, usa una factory:

java
1@Configuration
2@RequiredArgsConstructor
3public class JwtServiceFactory {
4
5    @Value("${jwt.algorithm}")
6    private String algorithm;
7
8    @Bean
9    @ConditionalOnProperty(name = "jwt.algorithm", havingValue = "HS256")
10    public JwtService hmacJwtService() {
11        return new HmacJwtService(); // Usa clave simétrica
12    }
13
14    @Bean
15    @ConditionalOnProperty(name = "jwt.algorithm", havingValue = "RS256")
16    public JwtService rsaJwtService(RSAPrivateKey privateKey, RSAPublicKey publicKey) {
17        return new RsaJwtService(privateKey, publicKey); // Usa par de claves
18    }
19}
Nunca uses la misma clave secreta en dev y prod. Si un desarrollador filtra la clave de desarrollo, no debe comprometer producción. Cada ambiente debe tener sus propias claves generadas de forma independiente.

Checklist de Seguridad para Producción

Antes de hacer deploy de tu sistema JWT a producción, revisa cada punto de este checklist:

Algoritmo y Claves

  • ☑ Usar RS256 o ES256 en producción (o HS256 con clave de al menos 256 bits)
  • ☑ Claves almacenadas en vault de secretos (nunca en código fuente ni archivos de configuración)
  • ☑ Plan de rotación de claves cada 90 días
  • ☑ Algoritmo none bloqueado explícitamente
  • ☑ Validación de algoritmo en el parser (prevención de algorithm confusion)

Tokens

  • ☑ Access token con expiración máxima de 60 minutos (idealmente 15)
  • ☑ Refresh token con expiración máxima de 30 días (idealmente 7)
  • ☑ JTI (JWT ID) único en cada token para blacklisting
  • ☑ Refresh token con rotación: cada uso genera un nuevo par
  • ☑ Detección de reutilización de refresh tokens revocados
  • ☑ Blacklisting de access tokens con Redis y TTL automático

Transporte y Almacenamiento

  • ☑ HTTPS obligatorio en todos los endpoints (HSTS habilitado)
  • ☑ Refresh tokens en cookies HttpOnly, Secure, SameSite=Strict
  • ☑ Access tokens solo en memoria (no localStorage)
  • ☑ Headers de seguridad: X-Content-Type-Options, X-Frame-Options, CSP

Protección contra Ataques

  • ☑ Rate limiting en /auth/login (máximo 5 intentos / 15 min)
  • ☑ Rate limiting en /auth/refresh (máximo 10 / 5 min)
  • ☑ CORS configurado solo para dominios permitidos
  • ☑ CSRF deshabilitado solo porque usamos tokens Bearer (no cookies de sesión)
  • ☑ Payload del JWT sin datos sensibles (PII, contraseñas, tokens de terceros)

Auditoría y Monitoreo

  • ☑ Logging de todos los eventos de seguridad (login, logout, refresh, acceso denegado)
  • ☑ TransactionId / correlationId en todos los logs
  • ☑ Alertas configuradas para actividad sospechosa (múltiples login fallidos)
  • ☑ Retención de logs de auditoría por al menos 90 días
  • ☑ Dashboard de monitoreo (Grafana, Datadog, CloudWatch)

Plan de Contingencia

  • ☑ Procedimiento documentado para compromiso de claves
  • ☑ Capacidad de revocar todos los tokens activos en emergencia
  • ☑ Backups de las claves de firma en ubicación segura separada
  • ☑ Runbook para incidentes de seguridad relacionados con autenticación
Si sospechas que una clave de firma fue comprometida: 1) Rota la clave inmediatamente. 2) Invalida todos los access tokens existentes (si usas blacklisting con Redis, agrega un "kill switch" global). 3) Revoca todos los refresh tokens en la base de datos. 4) Notifica a los usuarios afectados. 5) Investiga el origen de la filtración.

Conclusión del Curso

¡Felicidades! Has completado el Curso JWT en Spring Boot 3. A lo largo de cuatro artículos construimos un sistema de autenticación y autorización de nivel producción:

Resumen de lo construido

text
1┌─────────────────────────────────────────────────────────┐
2│                    ARQUITECTURA FINAL                   │
3├─────────────────────────────────────────────────────────┤
4│                                                         │
5│  Cliente (SPA/Mobile)                                   │
6│    │                                                    │
7│    ▼                                                    │
8│  ┌─────────────────────────────────┐                    │
9│  │      Rate Limit Filter          │  ← Bucket4j       │
10│  └──────────────┬──────────────────┘                    │
11│                 │                                       │
12│                 ▼                                       │
13│  ┌─────────────────────────────────┐                    │
14│  │   JWT Authentication Filter     │  ← Valida token   │
15│  │   • Extrae claims               │  ← Roles/Perms    │
16│  │   • Verifica blacklist (Redis)  │  ← JTI check      │
17│  └──────────────┬──────────────────┘                    │
18│                 │                                       │
19│                 ▼                                       │
20│  ┌─────────────────────────────────┐                    │
21│  │   @PreAuthorize                 │  ← RBAC           │
22│  │   hasRole / hasAuthority        │                    │
23│  └──────────────┬──────────────────┘                    │
24│                 │                                       │
25│                 ▼                                       │
26│  ┌─────────────────────────────────┐                    │
27│  │   Controllers + Services        │                    │
28│  └──────────────┬──────────────────┘                    │
29│                 │                                       │
30│                 ▼                                       │
31│  ┌────────┐  ┌────────┐  ┌────────────────┐            │
32│  │ Users  │  │ Roles  │  │ Refresh Tokens │            │
33│  │  (DB)  │  │  (DB)  │  │     (DB)       │            │
34│  └────────┘  └────────┘  └────────────────┘            │
35│                                                         │
36│  ┌─────────────────────────────────┐                    │
37│  │   Redis                         │                    │
38│  │   • Token Blacklist (JTI+TTL)  │                    │
39│  │   • Rate Limit (distribuido)    │                    │
40│  └─────────────────────────────────┘                    │
41│                                                         │
42│  ┌─────────────────────────────────┐                    │
43│  │   Security Audit Service        │                    │
44│  │   • MDC + JSON Logs             │                    │
45│  │   • ELK / Grafana Loki          │                    │
46│  └─────────────────────────────────┘                    │
47│                                                         │
48└─────────────────────────────────────────────────────────┘

Lo que cubrimos en cada parte

  1. Parte 1 — Fundamentos: Estructura de un JWT, JJWT 0.12, JwtService, JwtAuthenticationFilter, SecurityFilterChain, protección de endpoints por roles.
  2. Parte 2 — Refresh Tokens: Entidad JPA, rotación de tokens, detección de reutilización, cookies HttpOnly, manejo de errores con ProblemDetail.
  3. Parte 3 — JTI y Blacklisting: JWT ID único, blacklisting con Redis y TTL automático, logout real que invalida ambos tokens, limpieza de tokens expirados.
  4. Parte 4 — Producción: Claims personalizados, RBAC completo, @PreAuthorize, algoritmos de firma (HS256/RS256/ES256), seguridad OWASP, rate limiting, auditoría, testing y configuración por ambiente.

Próximos pasos

Tu sistema de autenticación JWT es sólido y está listo para producción. Aquí hay algunas áreas donde puedes seguir mejorando:

  • MFA / TOTP: Agrega autenticación de dos factores con códigos temporales (Google Authenticator, Authy). Usa la librería dev.samstevens.totp.
  • OAuth2 / OpenID Connect: Permite login con Google, GitHub o Microsoft usando Spring Authorization Server.
  • Autenticación en microservicios: Usa un API Gateway (Spring Cloud Gateway) como punto central de autenticación y propaga el JWT a los servicios internos.
  • Passkeys / WebAuthn: La nueva generación de autenticación sin contraseñas basada en biometría y claves criptográficas.
  • Monitoreo avanzado: Integra con OpenTelemetry para trazabilidad distribuida de tus flujos de autenticación.
Recuerda: La seguridad no es un destino, es un proceso continuo. Mantente al día con las vulnerabilidades publicadas, actualiza tus dependencias regularmente, y revisa tu configuración de seguridad periódicamente.

Gracias por seguir este curso completo. Si tienes preguntas o sugerencias, déjalas en los comentarios. ¡Éxito con tu proyecto!

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.