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¶
<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¶
# 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
@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
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¶
@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¶
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:
OncePerRequestFiltergarantiza 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
SecurityContextse limpia al terminar la petición: no hay sesión que reutilizar.
6. SecurityConfig: las reglas en un solo lugar¶
@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¶
@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¶
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));
}
@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);
}
}
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.
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));
}
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:
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:
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-securityy las tres dependencias de JJWT en elpom.xml. -
jwt.secretleído de una variable de entorno, nunca escrito en el repositorio. - Contraseñas guardadas con
BCryptPasswordEncoder. -
UsuarioDetailsServiceimplementado contra el repositorio de usuarios. -
JwtServicecongenerarToken,generarRefreshTokenyvalidarToken. -
JwtAuthenticationFilterregistrado conaddFilterBeforey rechazando refresh tokens. -
SecurityConfigen modoSTATELESS, con CSRF desactivado y CORS configurado. -
/auth/login,/auth/registroy/auth/refreshpúblicos; el resto autenticado. - Los controladores devuelven DTO, nunca la entidad
Usuario.
Referencias¶
- Ejemplo completo: github.com/pablomarquezfernandez/jwt-demo
- El lado del cliente: JWT en el frontend
- Spring Security · Architecture
- JJWT · jwt.io · RFC 7519