JWT + Spring Boot: Seguridad Stateless Sin Drama
JWT (JSON Web Token) es una de esas tecnologías que en teoría parecen simples, pero cuando tenés que implementarlas por primera vez con Spring Boot, te encontrás con más interrogantes que respuestas.
La buena noticia es que una vez que entendés el concepto y armás un ejemplo que funcione, todo hace click. La mala es que la mayoría de los tutoriales se van por las ramas y terminás más confundido que al principio.
Hoy vamos a armar JWT + Spring Security de manera práctica, sin florituras, pero con todo lo que necesitás para entender qué está pasando.
Qué es JWT y por qué nos importa
Un JWT es básicamente un sobre digital que contiene información y viene con su propia firma de autenticidad. Como esos sobres lacrados de las películas medievales, pero versión siglo XXI.
La diferencia principal con las sessions tradicionales es que toda la información está en el token mismo. No necesitás ir a buscar nada a una base de datos o cache. El token te dice quién es el usuario, qué permisos tiene, y cuándo expira.
Esto es útil cuando:
- Tenés múltiples servicios que necesitan saber quién es el usuario
- Querés que tu backend sea stateless (sin memoria de sesiones)
- Necesitás que el authentication funcione entre diferentes dominios
- Tenés una arquitectura de microservicios
JWT vs Sessions: cuándo usar cada uno
No todo es JWT en la vida. A veces las sessions tradicionales siguen siendo la mejor opción.
Usá JWT cuando:
- Tenés APIs que consumen aplicaciones móviles o SPAs
- Necesitás autenticación entre diferentes servicios
- Querés escalabilidad horizontal sin sticky sessions
- Tenés una arquitectura distribuida
Usá sessions cuando:
- Tenés una aplicación web tradicional (server-side rendering)
- Necesitás invalidar sesiones inmediatamente (logout forzoso)
- La aplicación es simple y todo corre en un solo servidor
- No querés lidiar con refresh tokens y rotación de claves
Pin Pun Pam. Cada herramienta para su trabajo.
Anatomía de un JWT
Un JWT tiene tres partes separadas por puntos:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header (primera parte): Especifica el algoritmo de encriptación y el tipo de token
{
"alg": "HS256",
"typ": "JWT"
}
Payload (segunda parte): La información que querés transmitir
{
"sub": "user123",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622
}
Signature (tercera parte): La firma que garantiza que nadie tocó el contenido
La clave está en que cualquiera puede leer el header y payload (están en Base64, no encriptados), pero solo quien tiene la clave secreta puede verificar que la signature es válida.
Es como un sello oficial: todos pueden ver el documento, pero solo la autoridad puede validar que el sello es genuino.
Spring Security + JWT: cómo se llevan
Spring Security por defecto maneja sessions. Para que funcione con JWT necesitamos hacer algunos ajustes:
- Deshabilitar sessions - Le decimos que sea stateless
- Agregar un filtro - Para interceptar requests y validar tokens
- Configurar endpoints - Cuáles necesitan autenticación y cuáles no
La idea es que Spring Security tome cada request, vea si tiene un token JWT válido en el header Authorization, y si es así, configure el contexto de seguridad con la información del usuario.
Manos a la obra: implementación paso a paso
Arrancamos con las dependencias. En tu pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
1. Configuración básica en application.properties
# JWT Configuration
app.jwt.secret=mySecretKey12345678901234567890123456789012345678901234567890
app.jwt.expiration=86400000
# H2 Database
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.h2.console.enabled=true
2. Servicio para manejar JWT
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration}")
private Long expiration;
public String generateToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
public String getUsernameFromToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public boolean isTokenValid(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
public boolean isTokenExpired(String token) {
Date expiration = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody()
.getExpiration();
return expiration.before(new Date());
}
}
3. Filtro para interceptar requests
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtService.isTokenValid(token) && !jwtService.isTokenExpired(token)) {
String username = jwtService.getUsernameFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
4. Configuración de Security
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.requestMatchers("/api/auth/login", "/api/public/**", "/h2-console/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
5. Los tres endpoints
Endpoint público:
@RestController
@RequestMapping("/api/public")
public class PublicController {
@GetMapping("/hello")
public String hello() {
return "Hello World! Este endpoint es público.";
}
}
Endpoint de login:
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
);
String token = jwtService.generateToken(request.getUsername());
return ResponseEntity.ok(new JwtResponse(token));
} catch (BadCredentialsException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Credenciales inválidas");
}
}
}
Endpoint protegido:
@RestController
@RequestMapping("/api/protected")
public class ProtectedController {
@GetMapping("/profile")
public String getProfile(Authentication authentication) {
return "Hola " + authentication.getName() + "! Este endpoint está protegido.";
}
}
6. DTOs para requests y responses
public class LoginRequest {
private String username;
private String password;
// getters y setters
}
public class JwtResponse {
private String token;
private String type = "Bearer";
public JwtResponse(String token) {
this.token = token;
}
// getters y setters
}
Cuestiones que van a aparecer
1. El secreto hardcodeado
El error:
app.jwt.secret=mysecret
Por qué está mal: Un secreto de 8 caracteres se puede romper en segundos. JWT usa HMAC que necesita claves robustas.
La solución:
# Mínimo 32 caracteres para HS256
app.jwt.secret=mySecretKey12345678901234567890123456789012345678901234567890
Y en producción, usá variables de entorno:
app.jwt.secret=${JWT_SECRET:defaultSecretForDevelopment12345678901234567890}
2. Headers HTTP mal formateados
El error que sempre aparece:
// Frontend manda:
Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6..."
// Debería mandar:
Authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6..."
Por qué falla: Spring Security espera el formato Bearer <token>. Sin “Bearer “, el filtro no reconoce que es un JWT.
Solución en el frontend:
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
Testing: La red de seguridad que necesitás
Los tests no son opcionales cuando trabajás con autenticación. Un bug en JWT puede comprometer todo tu sistema. En el repositorio vas a encontrar una suite completa de tests que incluye:
Tests unitarios del JwtService
@ExtendWith(MockitoExtension.class)
class JwtServiceTest {
@Test
@DisplayName("Should generate valid JWT token for authenticated user")
void shouldGenerateValidToken() {
// Given
String username = "testuser";
// When
String token = jwtService.generateToken(username);
// Then
assertThat(token).isNotNull();
assertThat(jwtService.getUsernameFromToken(token)).isEqualTo(username);
assertThat(jwtService.isTokenValid(token)).isTrue();
assertThat(jwtService.isTokenExpired(token)).isFalse();
}
@Test
@DisplayName("Should reject expired tokens")
void shouldRejectExpiredTokens() {
// Simular token expirado usando ReflectionTestUtils
ReflectionTestUtils.setField(jwtService, "expiration", -1000L);
String expiredToken = jwtService.generateToken("user");
assertThat(jwtService.isTokenExpired(expiredToken)).isTrue();
}
}
Tests de integración completos
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Transactional
class SecurityIntegrationTest {
@Test
void shouldAllowAccessToPublicEndpoint() throws Exception {
mockMvc.perform(get("/api/public/info"))
.andExpect(status().isOk());
}
@Test
void shouldDenyAccessWithoutToken() throws Exception {
mockMvc.perform(get("/api/protected/user"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldAllowAccessWithValidToken() throws Exception {
// Login y obtener token
String token = performLoginAndGetToken("admin", "admin123");
// Usar token para acceder a endpoint protegido
mockMvc.perform(get("/api/protected/user")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("admin"));
}
}
Antes de mandarlo a producción
Algunas cosas importantes:
1. Rotación de claves: En producción, las claves JWT deberían rotar periódicamente.
2. Refresh tokens: Para sesiones largas, implementá refresh tokens que permitan obtener nuevos JWTs sin reautenticarse.
3. Logout: Con JWT no tenés logout “real” (el token sigue siendo válido hasta que expire). Para logout inmediato necesitás una blacklist de tokens.
4. HTTPS obligatorio: JWT sin HTTPS es como dejar la puerta abierta. Los tokens viajan en headers HTTP.
5. Tiempo de expiración: No hagas tokens que duren semanas. 15 minutos a 1 hora es un buen balance entre seguridad y UX.
Repositorio Completo y Funcional
Todo el código de este tutorial (y mucho más) está disponible en el repositorio:
Lo que vas a encontrar:
- Código 100% funcional - Probado y listo para usar
- Documentación completa - README con ejemplos paso a paso
- Tests de integración - Suite completa de pruebas
- Scripts de prueba - Comandos curl para probar todos los endpoints
- Arquitectura detallada - Explicación de cada componente
- Usuarios pre-configurados - Para empezar a probar inmediatamente
Cómo empezar:
git clone https://github.com/rcantore/spring-jwt-tutorial-completo.git
cd spring-jwt-tutorial-completo
./mvnw spring-boot:run
# En otra terminal:
curl http://localhost:8080/api/public/info
Endpoints listos para probar:
Login como admin:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
Acceder a endpoint protegido:
# Usar el token del login anterior
curl -X GET http://localhost:8080/api/protected/user \
-H "Authorization: Bearer <tu-token-aqui>"
Probar autorización por roles:
# Solo funciona con rol ADMIN
curl -X GET http://localhost:8080/api/protected/admin \
-H "Authorization: Bearer <token-de-admin>"
El repositorio incluye ejemplos completos, manejo de errores, autorización por roles, y todo lo que necesitás para entender JWT en Spring Boot sin drama.
Deja un comentario