Saltar a contenido

Seguridad con JWT en Spring Boot

Quién eres (autenticación) y qué puedes hacer (autorización), sin que el servidor guarde sesiones.

Pregunta guía

Si la API no guarda sesiones, ¿cómo sabe en cada petición quién la está llamando?

Proyecto de ejemplo

Todo el código de esta página sale de github.com/pablomarquezfernandez/jwt-demo (Spring Boot, Spring Security, JJWT 0.12.6 y H2 en memoria). El lado del cliente está en JWT en el frontend.

Sesión con estado vs. JWT

Sesión tradicional JWT · stateless
Quién recuerda El servidor: guarda la sesión en memoria El cliente: cada petición trae el token
Qué viaja Una cookie (JSESSIONID) Authorization: Bearer eyJhbGciOi…
Escalar a varias instancias Hay que compartir las sesiones Cualquier instancia valida el token sola
Cerrar sesión El servidor borra la sesión El token sigue válido hasta que vence

Con JWT el servidor no recuerda nada: valida la firma del token en cada petición y actúa en consecuencia. Eso es lo que lo hace apto para una API consumida por una SPA o por varios clientes.

Qué es un JWT

Tres partes separadas por puntos, codificadas en Base64URL:

eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJhbmEiLCJyb2xlcyI6WyJST0xFX1VTRVIiXX0 . SflKxwRJSMeKKF2QT4fwpM
      header          .                    payload                     .        firma HS256
Parte Contenido
Header El algoritmo de firma: {"alg":"HS256"}
Payload Los claims: sub (el usuario), roles, iss (emisor), iat (emitido), exp (vence)
Firma HMAC-SHA256 del header y el payload con una clave que solo conoce el servidor

El payload no está cifrado

Cualquiera puede leer el contenido de un JWT pegándolo en jwt.io: es Base64, no cifrado. Nunca se ponen contraseñas ni datos sensibles en el payload. Lo que el atacante no puede hacer es modificarlo: sin la clave, la firma deja de coincidir y el token se rechaza.

Las piezas del lado del servidor

Clase Responsabilidad
JwtService Genera y valida los tokens. Único lugar que conoce la clave de firma
JwtAuthenticationFilter Se ejecuta en cada petición: lee el header Authorization y autentica
SecurityConfig Define qué rutas son públicas, cuáles piden token y cuáles piden rol
UsuarioDetailsService Le dice a Spring Security cómo buscar un usuario en la base de datos
AuthService y AuthController Registro, login y refresh: los únicos endpoints públicos de /auth
flowchart LR
    C["Cliente<br/>Authorization: Bearer …"] --> F["JwtAuthenticationFilter<br/>valida firma, emisor y exp"]
    F --> S["SecurityConfig<br/>¿esta ruta permite a este usuario?"]
    S --> R["@RestController<br/>la lógica de negocio"]
    F -. "token ausente o inválido" .-> E401["401 Unauthorized"]
    S -. "falta el rol" .-> E403["403 Forbidden"]

1. Dependencias

pom.xml
<properties>
    <jjwt.version>0.12.6</jjwt.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!-- JJWT: api en compilación; impl y jackson solo en ejecución -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>${jjwt.version}</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>${jjwt.version}</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>${jjwt.version}</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

2. Configuración

src/main/resources/application.properties
# Clave HMAC de 256 bits en Base64 para firmar los tokens (HS256).
# Para generar una nueva: openssl rand -base64 32
jwt.secret=${JWT_SECRET:J4sNjwV/P17WfWi7Xx6Pyu+/T6fEdEGdPW9npV8ZRio=}

# Tiempo de vida del access token, en minutos
jwt.expiration-minutes=15

# Tiempo de vida del refresh token, en días
jwt.refresh-expiration-days=7

# Origen del frontend Angular que puede llamar a la API desde el navegador (CORS)
cors.allowed-origin=${CORS_ALLOWED_ORIGIN:http://localhost:4200}

La clave no se sube al repositorio

El valor por defecto existe solo para que el ejemplo arranque. En un despliegue real se entrega por la variable de entorno JWT_SECRET (en Kubernetes, con un Secret). Quien tenga esa clave puede fabricar tokens válidos para cualquier usuario y cualquier rol.

3. El usuario en la base de datos

La contraseña nunca se guarda en texto plano: se guarda su hash BCrypt.

entity/Usuario.java
@Entity
@Table(name = "usuarios")
public class Usuario {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    /** Hash BCrypt: la contraseña nunca se guarda en texto plano. */
    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Rol rol;   // enum Rol { USER, ADMIN }
}
service/UsuarioDetailsService.java
@Service
public class UsuarioDetailsService implements UserDetailsService {

    private final UsuarioRepository usuarioRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {
        Usuario usuario = usuarioRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("Usuario no encontrado: " + username));

        return User.withUsername(usuario.getUsername())
                .password(usuario.getPassword())
                .roles(usuario.getRol().name())   // .roles("ADMIN") produce la autoridad ROLE_ADMIN
                .build();
    }
}

roles vs. authorities

.roles("ADMIN") agrega automáticamente el prefijo ROLE_, así que la autoridad real es ROLE_ADMIN. Por eso en SecurityConfig se escribe hasRole("ADMIN") (sin prefijo) y en el frontend se compara contra ROLE_ADMIN (con prefijo).

4. JwtService: generar y validar

security/JwtService.java
@Service
public class JwtService {

    private static final String EMISOR = "jwt-demo";

    private final SecretKey clave;
    private final Duration expiracion;

    public JwtService(@Value("${jwt.secret}") String secreto,
                      @Value("${jwt.expiration-minutes}") long minutos) {
        this.clave = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secreto));
        this.expiracion = Duration.ofMinutes(minutos);
    }

    /** Lo llama AuthService.login, después de validar la contraseña. */
    public String generarToken(UserDetails usuario) {
        Instant ahora = Instant.now();
        List<String> roles = usuario.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        return Jwts.builder()
                .subject(usuario.getUsername())          // sub
                .claim("roles", roles)                   // ["ROLE_USER"]
                .issuer(EMISOR)                          // iss
                .issuedAt(Date.from(ahora))              // iat
                .expiration(Date.from(ahora.plus(expiracion)))  // exp
                .signWith(clave, Jwts.SIG.HS256)
                .compact();
    }

    /** Verifica la firma, la expiración y el emisor, y devuelve el payload. */
    public Claims validarToken(String token) {
        return Jwts.parser()
                .verifyWith(clave)
                .requireIssuer(EMISOR)
                .build()
                .parseSignedClaims(token)   // lanza JwtException si algo no cuadra
                .getPayload();
    }
}

parseSignedClaims hace tres comprobaciones en una sola llamada: la firma coincide con la clave, exp no ha pasado y iss es el esperado. Si alguna falla lanza JwtException.

5. El filtro: leer el token en cada petición

security/JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String PREFIJO = "Bearer ";
    private final JwtService jwtService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (header == null || !header.startsWith(PREFIJO)) {
            chain.doFilter(request, response);   // petición anónima: puede ser una ruta pública
            return;
        }

        try {
            Claims claims = jwtService.validarToken(header.substring(PREFIJO.length()));

            List<SimpleGrantedAuthority> roles = ((List<?>) claims.get("roles", List.class)).stream()
                    .map(rol -> new SimpleGrantedAuthority(rol.toString()))
                    .toList();

            var autenticacion = UsernamePasswordAuthenticationToken
                    .authenticated(claims.getSubject(), null, roles);
            SecurityContextHolder.getContext().setAuthentication(autenticacion);

        } catch (JwtException | IllegalArgumentException e) {
            // Token alterado, expirado o mal formado: la petición sigue sin usuario y Spring responde 401
            SecurityContextHolder.clearContext();
        }

        chain.doFilter(request, response);
    }
}

Tres detalles que suelen costar un rato de depuración:

  • OncePerRequestFilter garantiza que el filtro corra una sola vez por petición, incluso con forwards internos.
  • El filtro no rechaza la petición: solo autentica si puede. Quien decide si hace falta estar autenticado es SecurityConfig.
  • El SecurityContext se limpia al terminar la petición: no hay sesión que reutilizar.

6. SecurityConfig: las reglas en un solo lugar

config/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http, JwtService jwtService) throws Exception {
        http
            // El frontend Angular corre en otro origen (localhost:4200)
            .cors(Customizer.withDefaults())
            // Sin cookies de sesión no hay CSRF que proteger
            .csrf(AbstractHttpConfigurer::disable)
            // Sin estado: el servidor no guarda sesiones
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                // PÚBLICOS: no necesitan token
                .requestMatchers(HttpMethod.POST, "/auth/login", "/auth/registro", "/auth/refresh").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/publico/**", "/api/productos/**").permitAll()
                .requestMatchers("/error").permitAll()
                // SOLO ADMIN
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.DELETE, "/api/productos/**").hasRole("ADMIN")
                // CON TOKEN: todo lo demás
                .anyRequest().authenticated())
            // Sin token válido: 401 (con token pero sin el rol: 403)
            .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
            // El filtro JWT corre antes del filtro de usuario y contraseña
            .addFilterBefore(new JwtAuthenticationFilter(jwtService), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
        DaoAuthenticationProvider proveedor = new DaoAuthenticationProvider(uds);
        proveedor.setPasswordEncoder(encoder);
        return new ProviderManager(proveedor);
    }
}

El orden de las reglas importa

Se evalúan de arriba hacia abajo y gana la primera que coincide. Por eso anyRequest().authenticated() va al final: si estuviera arriba, taparía todas las demás.

CORS: sin esto el navegador bloquea las llamadas

config/SecurityConfig.java
@Bean
CorsConfigurationSource corsConfigurationSource(@Value("${cors.allowed-origin}") String origen) {
    CorsConfiguration cors = new CorsConfiguration();
    cors.setAllowedOrigins(List.of(origen));
    cors.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    cors.setAllowedHeaders(List.of("Authorization", "Content-Type"));   // Authorization es el que lleva el token

    UrlBasedCorsConfigurationSource fuente = new UrlBasedCorsConfigurationSource();
    fuente.registerCorsConfiguration("/**", cors);
    return fuente;
}

Si se olvida Authorization en setAllowedHeaders, el preflight OPTIONS falla y el frontend ve un error de red sin código de estado: el síntoma clásico de status: 0 descrito en Excepciones del frontend.

7. Login y registro

service/AuthService.java
public LoginResponse login(LoginRequest request) {
    // 1. Spring Security busca al usuario y compara la contraseña con el hash BCrypt.
    //    Si no coincide lanza AuthenticationException y el handler responde 401.
    Authentication autenticacion = authenticationManager.authenticate(
            UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));

    // 2. Recién ahora se genera el token
    return emitirTokens((UserDetails) autenticacion.getPrincipal());
}

@Transactional
public UsuarioDTO registrar(RegistroRequest request) {
    if (usuarioRepository.existsByUsername(request.username())) {
        throw new UsuarioDuplicadoException(request.username());
    }
    Usuario usuario = new Usuario(request.username(), passwordEncoder.encode(request.password()), Rol.USER);
    return UsuarioDTO.desde(usuarioRepository.save(usuario));
}
controller/AuthController.java
@RestController
@RequestMapping("/auth")
public class AuthController {

    @PostMapping("/registro")
    @ResponseStatus(HttpStatus.CREATED)
    public UsuarioDTO registrar(@Valid @RequestBody RegistroRequest request) {
        return authService.registrar(request);
    }

    @PostMapping("/login")
    public LoginResponse login(@Valid @RequestBody LoginRequest request) {
        return authService.login(request);
    }
}
dto/LoginResponse.java
public record LoginResponse(String token, String refreshToken, String tipo, long expiraEnSegundos) { }
sequenceDiagram
    participant C as Cliente
    participant A as AuthController
    participant M as AuthenticationManager
    participant D as Base de datos
    participant J as JwtService
    C->>A: POST /auth/login { username, password }
    A->>M: authenticate(usuario, clave)
    M->>D: loadUserByUsername
    D-->>M: usuario con hash BCrypt
    Note over M: compara la contraseña con el hash
    M-->>A: Authentication con roles
    A->>J: generarToken(usuario)
    J-->>A: eyJhbGciOiJIUzI1NiJ9…
    A-->>C: 200 { token, refreshToken, tipo, expiraEnSegundos }

Los DTO nunca exponen la entidad

UsuarioDTO devuelve id, username y rol; jamás el hash de la contraseña. Devolver la entidad Usuario filtraría el campo password en el JSON.

8. Refresh token

El access token dura 15 minutos. Para no pedir la contraseña cada 15 minutos, el login entrega dos tokens:

Access token Refresh token
Duración 15 minutos 7 días
Se envía a Toda la API, en Authorization Solo POST /auth/refresh, en el cuerpo
Contiene sub, roles, iss, iat, exp sub, tipo: refresh, iss, iat, exp
¿Sirve para entrar a la API? Sí No: el filtro lo rechaza

Los dos se firman con la misma clave y el mismo emisor; los distingue el claim tipo.

security/JwtService.java
public String generarRefreshToken(UserDetails usuario) {
    Instant ahora = Instant.now();
    return Jwts.builder()
            .subject(usuario.getUsername())
            .claim("tipo", "refresh")        // lo distingue del access token
            .issuer(EMISOR)
            .issuedAt(Date.from(ahora))
            .expiration(Date.from(ahora.plus(expiracionRefresh)))
            .signWith(clave, Jwts.SIG.HS256)
            .compact();                      // sin roles: no sirve para entrar a la API
}

public boolean esRefreshToken(Claims claims) {
    return "refresh".equals(claims.get("tipo", String.class));
}
service/AuthService.java
public LoginResponse refrescar(String refreshToken) {
    Claims claims;
    try {
        claims = jwtService.validarToken(refreshToken);
    } catch (JwtException | IllegalArgumentException e) {
        throw new TokenInvalidoException("Refresh token inválido o expirado");
    }
    if (!jwtService.esRefreshToken(claims)) {
        throw new TokenInvalidoException("El token enviado no es un refresh token");
    }
    // Recarga al usuario: un cambio de rol se refleja en el token nuevo
    return emitirTokens(userDetailsService.loadUserByUsername(claims.getSubject()));
}

El filtro debe rechazar un refresh token enviado en Authorization; si no, como no trae roles, terminaría en un NullPointerException:

security/JwtAuthenticationFilter.java
if (jwtService.esRefreshToken(claims)) {
    throw new JwtException("Se envió un refresh token como access token");
}
Caso en POST /auth/refresh Respuesta
Refresh token vigente 200 con token y refreshToken nuevos
Vencido, alterado o mal formado 401 {"error": "Refresh token inválido o expirado"}
Se envía un access token 401 {"error": "El token enviado no es un refresh token"}
Cuerpo sin refreshToken 400

Limitación: no hay revocación

Un refresh token robado sirve hasta que vence, y cerrar sesión en el cliente no lo invalida en el servidor. En producción se guarda en base de datos (o su jti) para poder revocarlo, y se rota: cada refresh invalida el anterior.

El lado del cliente —cuándo se llama a /auth/refresh y cómo se reintenta la petición— está en JWT en el frontend.

9. 401 y 403: no son lo mismo

Código Significa Causa típica
401 Unauthorized «No sé quién eres» No hay token, está vencido, alterado o no es nuestro
403 Forbidden «Sé quién eres, pero no puedes» El token es válido pero falta ROLE_ADMIN

Un 401 se resuelve renovando el token o volviendo al login; un 403, no: el token nuevo traería el mismo rol.

10. Probarlo con curl

# 1. Login: obtener el token
TOKEN=$(curl -s -X POST localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"ana","password":"ana123"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')

# 2. Usarlo en cada petición
curl localhost:8080/api/publico/info                                     # 200: público
curl localhost:8080/api/perfil -H "Authorization: Bearer $TOKEN"         # 200: con token
curl localhost:8080/api/perfil                                           # 401: sin token
curl localhost:8080/api/admin/usuarios -H "Authorization: Bearer $TOKEN" # 403: ana no es ADMIN

Pega el token en jwt.io para ver su header y su payload. Para ver el refresh sin esperar 15 minutos:

./mvnw spring-boot:run -Dspring-boot.run.arguments=--jwt.expiration-minutes=1

Errores frecuentes

Síntoma Causa habitual
Todo responde 401, incluso el login /auth/** no está en permitAll(), o anyRequest().authenticated() quedó antes que las reglas públicas
403 donde se esperaba 401 Falta el prefijo: la autoridad es ROLE_ADMIN y se comparó contra ADMIN con hasAuthority
El frontend ve un error de red sin código Falta CORS, o Authorization no está en setAllowedHeaders
WeakKeyException al arrancar La clave tiene menos de 256 bits: genera una con openssl rand -base64 32
Funciona en local y falla en el servidor JWT_SECRET distinto entre instancias: los tokens firmados por una no los acepta la otra
NullPointerException leyendo roles Llegó un refresh token en Authorization y el filtro no lo rechazó

Checklist

  • spring-boot-starter-security y las tres dependencias de JJWT en el pom.xml.
  • jwt.secret leído de una variable de entorno, nunca escrito en el repositorio.
  • Contraseñas guardadas con BCryptPasswordEncoder.
  • UsuarioDetailsService implementado contra el repositorio de usuarios.
  • JwtService con generarToken, generarRefreshToken y validarToken.
  • JwtAuthenticationFilter registrado con addFilterBefore y rechazando refresh tokens.
  • SecurityConfig en modo STATELESS, con CSRF desactivado y CORS configurado.
  • /auth/login, /auth/registro y /auth/refresh públicos; el resto autenticado.
  • Los controladores devuelven DTO, nunca la entidad Usuario.

Referencias