Configuración de JWT en Spring Boot para Proteger tus APIs REST
Proteger una API REST de Spring Boot con JWT requiere varios componentes clave que colaboran para autenticar usuarios y autorizar acceso a recursos protegidos. En este artículo se explica de forma clara y práctica cada pieza necesaria, desde la carga de usuarios hasta los filtros que validan los tokens en cada petición.
Siendo la autenticación la puerta para acceder a las aplicaciones, es el punto de inicio en la experiencia del usuario, es donde se debe garantizar la seguridad y la protección de su entidad. Un método para asegurar el proceso es el uso de JWT (JSON Web Token), que permite transmitir la información de manera segura, con una firma digital o un cifrado específico entre las partes involucradas en el manejo del JWT.
¿Por qué emplear JWT?
Una vez especificado su funcionamiento, composición e implementación podemos ver que el uso de este tipo de token le facilita al desarrollador un formato compacto y simple para transmitir información, brindando eficiencia al sistema que lo implemente. Estos tokens, al ser autónomos, brindan toda la información necesaria para la verificación de su validez, permitiendo reducir dependencias con servicios adicionales de validación y aportando alta escalabilidad.
Podemos firmar los JWTs con el cifrado que deseemos, aunque se recomienda utilizar algoritmos de cifrado como RSA, permitiendo proteger la información sensible que queramos transportar. Como podemos incluir cualquier tipo de datos en el payload, debido a su flexibilidad, contamos con la facilidad de adaptar el JWT a cualesquiera parámetros para la autenticación que queramos implementar en nuestra aplicación.
Estos tokens, al ser autónomos, brindan toda la información necesaria para la verificación de su validez, permitiendo reducir dependencias con servicios adicionales de validación y aportando alta escalabilidad. El servidor no guarda estado de sesión; el token transporta la información de autenticación y autorización. Esto mejora la escalabilidad y simplifica la arquitectura de microservicios.
Composición del JWT
Este token es compuesto por tres partes:
- Encabezado o Header: Contiene metadatos sobre el tipo de token empleado y el algoritmo asociado a la firma o cifrado.
- Payload: Esta sección contiene la información que se desea transmitir al utilizar el token. Public Claim Names: son valores personalizados, pero públicos. Pueden estar representados por una URL o por un nombre. Entre otras Claims, podemos enviar desde la IP de la máquina para la que se ha emitido un JWT, los ámbitos a los que se le permite acceder, hasta la fecha y hora de expiración del mismo.
- Firma: La cual valida el origen del token y permite verificar si ha sido modificado; los JWT son tokens autónomos, contando en sí con toda la información necesaria para verificar su validez. La firma se realiza usando una clave privada digital.
Flujo de Autenticación con JWT
En el proceso de autenticación, una vez el usuario realiza el inicio de sesión proporcionando sus credenciales correctas vinculadas al sistema, el servidor verifica dichas credenciales y retorna un JWT con la información relevante de dicho usuario. Una vez este token se genera, el cliente, ya sea una aplicación web o móvil, lo recibe y lo almacena, generalmente con una cookie del navegador o por medio de almacenamiento local, para garantizar la seguridad del código con HTTPS.
Ahora, por cada solicitud posterior en el flujo del sistema, el cliente incluye el JWT en los headers de autorización, siguiendo el esquema de autenticación Bearer. Este esquema es fácil de implementar y comprender, siendo compatible con arquitecturas escalables y distribuidas, el cual podemos implementar en nuestras aplicaciones Java con la clase Spark.
Cuando el sistema recibe una petición con un JWT procede a validar el proceso de autenticación. Primero, decodifica el token accediendo al encabezado y al payload, luego, verifica la firma del token para cerciorarse de que dicha autenticación no haya sido comprometida y, cuando la firma es validada, se verifica la validez de la información transportada como los permisos del usuario o el tiempo de validez del token.
4. Postman Tutorial - Generar Bearer token Autenticación
Dependencias Esenciales
Para realizar la implementación de esta autenticación, se requiere, de primera mano, configurar las dependencias específicas. Debemos agregar la dependencia de ‘jjwt’ para Gradle o Maven.
Para Gradle
dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.2' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'}Para Maven
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.2</version></dependency><dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.2</version> <scope>runtime</scope></dependency><dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.2</version> <scope>runtime</scope></dependency>
Con estas dependencias puedes generar, firmar y validar tokens JWT con algoritmos como HS256 y trabajar con claims personalizados. Añade los starters de Spring Boot para security y web y la librería jjwt para manejar JSON Web Tokens.
Componentes Clave en Spring Security
UserDetails
Representa al principal o usuario en Spring Security y contiene la información básica que necesita el framework para autenticación y autorización. Métodos clave: getAuthorities para permisos y roles, getPassword para la contraseña, getUsername para el identificador, y los booleanos isAccountNonExpired, isAccountNonLocked, isCredentialsNonExpired e isEnabled para estados del usuario.
UserDetailsService
Es el servicio responsable de cargar datos del usuario por nombre de usuario y devolver un objeto UserDetails. Implementa la lógica para buscar usuarios en base de datos o en otro origen. DaoAuthenticationProvider usa esta implementación para comparar credenciales y validar el acceso.
Authorities
Las autoridades o GrantedAuthority representan permisos o roles como ROLE_ADMIN o ROLE_USER. Se cargan junto con el usuario y son consultadas por Spring Security para decidir si una petición tiene permiso para acceder a un endpoint.
Codificador de Contraseñas (PasswordEncoder)
Usa un PasswordEncoder robusto como BCryptPasswordEncoder para hash irreversibles. Nunca almacenes contraseñas en texto plano. El encoder se registra como bean para que DaoAuthenticationProvider lo utilice al verificar credenciales.
AuthenticationProvider
Es el núcleo del proceso de autenticación. Un DaoAuthenticationProvider combina un UserDetailsService y un PasswordEncoder para autenticar UsernamePasswordAuthenticationToken. Registra el provider en la configuración de seguridad para que Spring lo use al autenticar.
JwtHelper (Clase Utilitaria)
Clase utilitaria que centraliza la generación, firma, parseo y validación de JWT. Funciones típicas: generar token con subject y claims, extraer username del token, comprobar expiración, validar firma y decodificar claves. Mantén la clave secreta en un almacén seguro y no en código fuente en claro.
import io.jsonwebtoken.Claims;import io.jsonwebtoken.Jwts;import io.jsonwebtoken.SignatureAlgorithm;import io.jsonwebtoken.security.Keys;import java.security.Key;import java.util.Date;public class JwtUtil { private static final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256); private static final long expirationTime = 21600000; // 6 horas en milisegundos public static String generateToken(String subject) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expirationTime); return Jwts.builder() .setSubject(subject) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(key) .compact(); } public static String getSubjectFromToken(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); return claims.getSubject(); } public static boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return true; } catch (Exception e) { return false; } }}Filtros de Seguridad
En Spring Security los filtros forman una cadena que intercepta peticiones. Dos filtros personalizados habituales son CustomAuthenticationFilter y CustomAuthorizationFilter.
- El primero gestiona el login, extrae credenciales, delega en AuthenticationProvider y, si la autenticación es correcta, genera y retorna access y refresh tokens.
- El segundo se ejecuta en cada petición, busca el encabezado Authorization, extrae y valida el JWT, carga el UserDetails correspondiente y establece la autenticación en el SecurityContext para que el resto de la aplicación reconozca al usuario.
import java.util.HashMap;import java.util.Map;public class AuthenticationService { private static Map<String, String> pragmaticos = new HashMap<>(); static { pragmaticos.put("pragmatico1", "contraseña1"); pragmaticos.put("pragmatico2", "contraseña2"); } public static String login(String pragName, String password) { if (pragmaticos.containsKey(pragName) && pragmaticos.get(pragName).equals(password)) { return JwtUtil.generateToken(pragName); } return null; } public static boolean isAuthenticated(String token) { return JwtUtil.validateToken(token); }}import spark.Filter;import spark.Request;import static spark.Spark.*;public class ResourceController { public static void main(String[] args) { before((Filter) (request, response) -> { String token = request.headers("Authorization"); if (token == null || !AuthenticationService.isAuthenticated(token)) { halt(401, "Acceso no autorizado"); } }); get("/protected", (req, res) -> { // Aquí la ruta está protegida y se puede acceder a la información del usuario autenticado si se necesita. return "¡Acceso concedido a recurso protegido!"; }); }}import io.jsonwebtoken.Claims;import io.jsonwebtoken.Jwts;import io.jsonwebtoken.security.Keys;import spark.Filter;import spark.Request;import spark.Response;import static spark.Spark.*;import java.security.Key;public class JWTAuthenticationExample { private static final Key secretKey = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256); public static String generateToken(String subject) { // Lógica de generación de token (puede usar JwtUtil) return Jwts.builder() .setSubject(subject) .signWith(secretKey) .compact(); } public static void main(String[] args) { post("/login", (req, res) -> { String pragmatico = req.queryParams("pragmatico"); String password = req.queryParams("password"); if ("pragmatico".equals(pragmatico) && "contraseña".equals(password)) { String token = generateToken(pragmatico); return token; } else { res.status(401); return "Credenciales inválidas"; } }); before((Filter) (request, response) -> { String token = request.headers("Authorization"); if (token == null || !token.startsWith("Bearer ")) { halt(401, "Token de autenticación no proporcionado"); } else { String jwtToken = token.substring(7); try { Claims claims = Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(jwtToken) .getBody(); request.attribute("user", claims.getSubject()); } catch (Exception e) { halt(401, "Token de autenticación inválido"); } } }); get("/protected", (req, res) -> { String username = req.attribute("user"); return "Usuario autenticado: " + username; }); }}Configuración de Seguridad (SecurityFilterChain)
SecurityFilterChain organiza reglas y filtros. Para APIs REST con JWT conviene desactivar CSRF, usar SessionCreationPolicy.STATELESS, configurar CORS para permitir solicitudes desde el frontend y añadir los filtros personalizados en el orden correcto. Permite rutas públicas como el endpoint de login y validate-token y exige autenticación para el resto.
Sobre CSRF
En aplicaciones stateful CSRF es relevante, pero en APIs REST stateless con tokens JWT suele deshabilitarse porque el token debe enviarse explícitamente en cada petición. Si el JWT se almacena en cookies es necesario evaluar medidas adicionales para mitigación de CSRF.
Sobre CORS
Cross Origin Resource Sharing controla qué orígenes pueden hacer peticiones al API. Configura allowed origins, métodos y headers para permitir que el frontend consuma la API sin bloqueos del navegador. En la práctica define el origen del frontend y los métodos HTTP permitidos como GET, POST, PUT y DELETE.
Sesión
Con JWT se recomienda una política stateless. El servidor no guarda estado de sesión; el token transporta la información de autenticación y autorización. Esto mejora la escalabilidad y simplifica la arquitectura de microservicios.
Almacenamiento de Tokens en el Frontend
No existe una solución perfecta para almacenar tokens JWT en el frontend, ya que cada enfoque tiene sus propias ventajas y riesgos.
Esta es una tabla comparativa de las opciones de almacenamiento en el frontend:
| Opción | Ventajas | Riesgos / Consideraciones |
|---|---|---|
| SessionStorage | Datos persisten mientras la pestaña/ventana está abierta. | Se pierde al cerrar la pestaña/ventana. No accesible desde otras pestañas/ventanas. |
| Cookies | Se envían automáticamente en cada solicitud HTTP. Atributos como HttpOnly, Secure, SameSite. | Expuestas a ataques CSRF (si no se configuran bien SameSite). Si no son HttpOnly, expuestas a XSS. Se envían en todas las solicitudes, exponiendo más el token. |
| LocalStorage | API sencilla. Permite mantener la sesión al navegar sin perderla. Persistencia entre sesiones. | Expuesto a ataques XSS (Cross-Site Scripting), ya que es accesible por JavaScript. |
La combinación de cookies HttpOnly para el refresh token (para XSS) y localStorage para el access token (para facilidad de uso y evitar envío innecesario) es una solución que combina seguridad contra XSS (gracias a las cookies HttpOnly) y facilidad de uso para la autenticación rápida con localStorage.
Buenas Prácticas Adicionales
- Rota claves y refresh tokens.
- Valida revocación de tokens cuando se cambian permisos o se cierra sesión.
- Limita claims sensibles.
- Aplica límites de tiempo adecuados para access y refresh tokens.
- Registra eventos de seguridad para auditoría.
Autenticación y Autorización con ASP.NET Core
En ASP.NET Core, la configuración de JWT implica el uso de ASP.NET Identity Core, un sistema de "Membership" que nos brinda una solución integral para la gestión de usuarios, autenticación y autorización.
Verificación de Credenciales y Generación de JWT
Utilizamos Identity de ASP.NET para gestionar a los usuarios (aunque tiene más funcionalidades, en este caso solo estamos utilizando esta parte) y sus roles. La generación del JWT se realiza a partir de los claims generados según el usuario autenticado. Este código es prácticamente un "boilerplate" y siempre será el mismo.
Configuración del Servicio de Autenticación
En ASP.NET Core, para integrar JWT, se configura el servicio de autenticación para usar Bearer Tokens. Esto generalmente se hace en el archivo Startup.cs o Program.cs (para .NET 6+).
public void ConfigureServices(IServiceCollection services){ // ... otras configuraciones services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, // ¡Ojo! Considerar validar el emisor ValidateAudience = true, // ¡Ojo! Considerar validar la audiencia ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = Configuration["Jwt:Issuer"], ValidAudience = Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"])) }; }); // ...}Autorización basada en Roles
Utilizamos el atributo [Authorize] en el controlador para requerir un esquema de autenticación. Los roles en Identity deben registrarse en la base de datos, ya que suelen ser fijos. La autorización basada en roles tiene como objetivo permitir que solo los usuarios con un rol específico puedan acceder a ciertas acciones. Al crear el JWT, revisamos esta relación. Con esta configuración de autenticación, puedes utilizar expresiones como User.IsInRole("Admin") para verificar si el usuario actual tiene un rol específico.
[Authorize(Roles = "Admin")][HttpPost("products")]public IActionResult CreateProduct([FromBody] ProductDto product){ // Lógica para crear un producto, solo accesible por usuarios con rol 'Admin' return Ok("Producto creado.");}Acceso al Usuario Actual
Acceder al usuario actual es una parte importante de la autorización y autenticación en una aplicación. Para lograrlo, necesitamos una forma de acceder al contexto actual del usuario. Para lograr esto, creamos una abstracción llamada ICurrentUserService que nos permitirá acceder al usuario actual. Aquí estamos definiendo el contrato para acceder al usuario actual.
public interface ICurrentUserService{ string UserId { get; } string Username { get; } bool IsInRole(string roleName);}public class CurrentUserService : ICurrentUserService{ private readonly IHttpContextAccessor _httpContextAccessor; public CurrentUserService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public string UserId => _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; public string Username => _httpContextAccessor.HttpContext?.User?.Identity?.Name; public bool IsInRole(string roleName) { return _httpContextAccessor.HttpContext?.User?.IsInRole(roleName) ?? false; }}HttpContext.User se inicializa automáticamente en ASP.NET cuando usamos Bearer Tokens, ya que hemos indicado que se espera un JWT en el encabezado Authorization.
Validación de JWT emitidos por Azure Active Directory
A la hora de validar un JWT emitido por un Azure Active Directory, lo primero que tendremos que hacer es validar la cabecera de la petición HTTP. Comprobaremos que tiene una cabecera llamada “Authorization” y un valor que puede ser dividido en dos, separándolo por un espacio vacío.
Después deberemos saber de dónde recuperar las claves públicas para comprobar la firma. Si desconocemos cuál es el tenantId que estamos usando, lo podemos leer (en el caso de Azure Active Directory) del propio token. Se guarda en una propiedad llamada “tid”. Por lo que, si cogemos el cuerpo del token, lo convertimos a un formato JSON y buscamos esta propiedad ya tenemos el tenantId. Ahora solo tenemos que realizar esa petición y de entre los diferentes datos que nos envía buscar un campo llamado “jwks_uri”. La respuesta de esta última consulta, tendremos que mirar en la propiedad “keys” y dentro de los objetos que contiene esta propiedad, las claves se almacenan en formato de string en la propiedad “x5c”. Ahora bastaría con coger estas cadenas que vienen en base 64 y convertirlas a un formato de clave conocido por nuestro sistema.
Para TypeScript vamos a usar un paquete npm llamado jsonwebtoken, que nos ayudará a tratar con JWTs. En este caso, las claves públicas hay que convertirlas en certificados X509 primero. Y la verificación del token se realiza usando un artefacto del paquete System.IdentityModel.Tokens.Jwt llamado JwtSecurityTokenHandler.
// Ejemplo conceptual en TypeScript para validar JWTimport * as jwt from 'jsonwebtoken';function validateAzureAdJwt(token: string) { // 1. Extraer el tenantId del token (ej. decodificando el payload) const decodedToken = jwt.decode(token, { complete: true }); if (!decodedToken || typeof decodedToken === 'string') { throw new Error('Invalid token format'); } const tenantId = (decodedToken.payload as any).tid; // 2. Obtener jwks_uri del endpoint de configuración de OpenID Connect // (Ej. https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration) // 3. Descargar las claves públicas (jwks) desde jwks_uri // 4. Convertir las claves x5c a certificados X509 // 5. Validar el token usando las claves públicas try { const decoded = jwt.verify(token, 'your_public_key_or_certificate', { algorithms: ['RS256'] // El algoritmo esperado de Azure AD // Aquí puedes añadir más opciones de validación como audience, issuer, etc. }); console.log('Token válido:', decoded); } catch (error) { console.error('Token inválido:', error); }}
