JWT: uso correcto y errores de seguridad comunes

1. Descripción general

JWT (JSON Web Token) se usa en todas partes para la autenticación de APIs, pero muchos sistemas lo implementan incorrectamente, resultando en vulnerabilidades graves. Este artículo recopila cómo crear y verificar JWT correctamente en Spring Boot, junto con los errores de seguridad clásicos (alg confusion, secret débil, falta de validación de expiración…) y cómo prevenirlos. Después de leerlo, sabrás cómo configurar JWT de forma segura y podrás revisar el código JWT de tus colegas con mayor confianza.

2. Estructura de JWT y mecanismo de funcionamiento

JWT consta de 3 partes: header.payload.signature, codificadas en base64url, separadas por puntos.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.4Z... (firma)
  • Header: contiene alg (algoritmo de firma) y typ.
  • Payload: contiene claims como sub, exp, iat, roles.
  • Signature: la firma para verificar que el token no ha sido modificado.

Nota: JWT no cifra el payload, solo lo codifica. Cualquiera puede leer el contenido si tiene el token. No coloques contraseñas o datos personales sensibles en los claims.

3. Agregar dependencias

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

La librería jjwt (io.jsonwebtoken) es la más popular para Java, con una API clara y que por defecto ya bloquea algunos de los errores más comunes.

4. Crear y verificar token correctamente

4.1. Generar una clave secreta lo suficientemente fuerte

import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;

public class JwtKeyFactory {

    public static SecretKey generateKey() {
        return Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
    }
}

Keys.secretKeyFor genera una clave aleatoria con la longitud adecuada para HS256 (≥ 256 bits). En producción, almacena esta clave en un gestor de secretos (Vault, AWS Secrets Manager), no en el código.

4.2. Crear token con TokenService

import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;

public class TokenService {

    private final SecretKey key;

    public TokenService(SecretKey key) {
        this.key = key;
    }

    public String generateToken(String username) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(username)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusSeconds(900))) // 15 minutos
                .signWith(key)
                .compact();
    }
}
  • expiration se establece en 15 minutos — un token de acceso debe tener una vida útil corta.
  • signWith(key) elige automáticamente el algoritmo apropiado para el tipo de clave, evitando que el cliente interfiera en alg.

4.3. Verificar token

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;

public class TokenVerifier {

    private final SecretKey key;

    public TokenVerifier(SecretKey key) {
        this.key = key;
    }

    public Claims verify(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (JwtException e) {
            throw new InvalidTokenException("Token inválido o expirado", e);
        }
    }
}

parseSignedClaims verifica automáticamente la firma y exp. Si la firma es incorrecta o el token ha expirado, lanza JwtException — la captas y la conviertes en una excepción de tu dominio.

5. Error de seguridad #1: alg=none o alg confusion

Un atacante modifica el header a {"alg":"none"} y envía un token sin firma. Si el backend no fuerza un algoritmo, podría aceptar un token falso.

// MAL: no se especifica el algoritmo permitido
Jwts.parser().build().parseSignedClaims(token); // versiones antiguas de la librería son propensas a esto

Cómo prevenirlo — siempre incluye un algoritmo en la whitelist:

import io.jsonwebtoken.Jwts;

Jwts.parser()
        .verifyWith(key)          // solo acepta la clave conocida
        .build()
        .parseSignedClaims(token); // jjwt 0.12+ bloquea automáticamente alg=none

Nota: Con otras librerías JWT (jose4j, nimbus-jose-jwt), siempre configura una whitelist de algoritmos explícitamente (por ejemplo, solo permitir HS256). Nunca confíes en el campo alg del token enviado por el cliente.

Una variante más peligrosa: alg confusion entre RS256 y HS256. Si el servidor verifica con una clave pública RSA pero luego usa esa misma clave como secreto para HMAC cuando alg=HS256, un atacante puede firmar el token usando la clave pública (que es pública). Solución: fija el algoritmo según el caso de uso, no dejes que el código lea alg del token y elija cómo verificar en consecuencia.

6. Error de seguridad #2: secret débil o secret expuesto

// MAL: secret corto, fácil de hacer fuerza bruta
String secret = "mysecret123";
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());

HS256 requiere una clave ≥ 256 bits (32 bytes). Un secret corto puede ser atacado por fuerza bruta offline usando herramientas como hashcat en pocas horas.

// CORRECTO: secret con longitud suficiente y aleatorio
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

Además:

  • No hagas commit del secret en Git. Usa variables de entorno o un gestor de secretos.
  • Rota el secret periódicamente, especialmente cuando personal con acceso al secret se va.
# application.properties - lee de env, no hardcodes
jwt.secret=${JWT_SECRET}

7. Error de seguridad #3: no validar expiration o token que vive demasiado tiempo

// MAL: token de acceso vive 30 días
.expiration(Date.from(now.plus(30, ChronoUnit.DAYS)))

Un token de acceso que vive demasiado tiempo significa que si se filtra, el atacante tendrá acceso prolongado que no puedes revocar (JWT es sin estado, no hay mecanismo de logout por defecto).

Patrón correcto: token de acceso corto (5–15 minutos) + refresh token más largo, almacenado en el servidor (o rotado) para poder revocarlo.

public class TokenPair {
    private final String accessToken;   // vive 15 minutos
    private final String refreshToken;  // vive 7 días, se almacena en BD para poder revocarlo

    // constructor, getters
}
public class RefreshTokenRepository {

    public boolean isRevoked(String refreshTokenId) {
        // consulta BD/Redis, si el token ha sido eliminado retorna true
        return false;
    }
}

Cuando se revoca un refresh token, el token de acceso antiguo sigue siendo válido hasta que expire — es por eso que el token de acceso debe ser corto.

8. Error de seguridad #4: colocar datos sensibles en el payload

// MAL: payload es público, cualquiera con el token puede leerlo
.claim("password", user.getPassword())
.claim("creditCard", user.getCardNumber())

El payload solo está codificado en base64, no cifrado. Decodificarlo en [jwt.io](texto plano, no es un enlace) muestra todo. Solo deberías poner claims necesarios para la autorización: sub, roles, exp, scope.

// CORRECTO
.subject(user.getUsername())
.claim("roles", user.getRoles())

9. Error de seguridad #5: no validar issuer/audience cuando se usa el mismo secret para múltiples servicios

Si varios servicios usan el mismo secret para firmar tokens, un token emitido para el Servicio A podría ser reproducido en el Servicio B si no se valida aud (audience).

import io.jsonwebtoken.Jwts;

Claims claims = Jwts.parser()
        .verifyWith(key)
        .requireAudience("order-service") // solo acepta tokens para este servicio
        .requireIssuer("auth-server")
        .build()
        .parseSignedClaims(token)
        .getPayload();

requireAudience y requireIssuer obligan al parser a lanzar una excepción si los claims no coinciden — previene que el token se use con un propósito incorrecto.

10. Integración con filtro de Spring Security

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

public class JwtAuthFilter extends OncePerRequestFilter {

    private final TokenVerifier verifier;

    public JwtAuthFilter(TokenVerifier verifier) {
        this.verifier = verifier;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws IOException, ServletException {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            try {
                var claims = verifier.verify(header.substring(7));
                var auth = new UsernamePasswordAuthenticationToken(
                        claims.getSubject(), null, List.of());
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (InvalidTokenException e) {
                SecurityContextHolder.clearContext();
            }
        }
        chain.doFilter(request, response);
    }
}
  • Obtiene el token del header Authorization: Bearer <token>.
  • Lo verifica usando TokenVerifier escrito en la sección 4.3.
  • Si el token es inválido, limpia el contexto — la solicitud será bloqueada por Spring Security en el siguiente paso de autorización, sin lanzar una excepción directamente en el filtro.

Nota: No almacenes JWT en localStorage si el frontend es una SPA pública — es vulnerable a XSS que podría robarlo. Es preferible almacenarlo en una cookie HttpOnly, Secure, SameSite=Strict cuando sea posible.

11. Pruebas del comportamiento de verificación de token

import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import javax.crypto.SecretKey;
import java.time.Instant;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;

class TokenVerifierTest {

    private final SecretKey key = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
    private final TokenService tokenService = new TokenService(key);
    private final TokenVerifier verifier = new TokenVerifier(key);

    @Test
    void shouldVerifyValidToken() {
        String token = tokenService.generateToken("alice");

        var claims = verifier.verify(token);

        assertThat(claims.getSubject()).isEqualTo("alice");
    }

    @Test
    void shouldRejectTokenSignedWithDifferentKey() {
        SecretKey otherKey = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
        TokenService fakeService = new TokenService(otherKey);
        String forgedToken = fakeService.generateToken("attacker");

        assertThatThrownBy(() -> verifier.verify(forgedToken))
                .isInstanceOf(InvalidTokenException.class);
    }
}

La segunda prueba simula exactamente la situación donde un atacante firma un token con una clave diferente — verifier debe rechazarlo, sin aceptarlo en silencio.

shouldVerifyValidToken()                 PASS
shouldRejectTokenSignedWithDifferentKey() PASS

12. Checklist rápido antes de desplegar

  • Secret ≥ 256 bits, almacenado en un gestor de secretos, no hardcodeado.
  • Algoritmo en whitelist, no confíes en alg del token.
  • Token de acceso con vida corta (≤ 15 minutos), con refresh token para poder revocarlo.
  • Sin datos sensibles en el payload.
  • Valida exp, iss, aud cuando sea necesario.
  • Almacena el token en una cookie HttpOnly en lugar de localStorage cuando el frontend es una SPA.
  • Tienes un mecanismo para revocar/blacklist el refresh token.

13. Conclusión

JWT en sí no tiene problemas — la mayoría de los incidentes provienen de cómo se implementa: secret débil, confiar en alg del cliente, token que vive demasiado tiempo, o colocar datos sensibles en un payload público. Aplicando el checklist de la sección 12 y siempre usando una librería JWT probada (jjwt, nimbus-jose-jwt) en lugar de escribir tu propio parser, evitarás la mayoría de las vulnerabilidades comunes. El siguiente paso que vale la pena investigar: OAuth 2.0 / OpenID Connect para mejor gestión del ciclo de vida de tokens a escala de múltiples servicios.

jwtweb-securityspring-bootauthenticationjava