Proteger REST API con Spring Security y JWT

1. Descripción general

Las API REST típicamente no usan sesiones. Cada solicitud debe llevar consigo la información de autenticación, y JWT (JSON Web Token) es la forma más común de hacerlo.

Este artículo te guía en proteger una API de gestión de pedidos con Spring Security 6 y Spring Boot 3. Emitiremos JWT cuando el usuario inicia sesión, autenticaremos el token en cada solicitud y autorizaremos según el rol. La última parte prueba automáticamente los comportamientos principales.

No escribiremos un filtro JWT manual. En su lugar, este artículo usa OAuth2 Resource Server que viene integrado en Spring Security. Este enfoque requiere menos código y es más seguro.

2. Dependencias Maven

Necesitas las siguientes dependencias en pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

El starter oauth2-resource-server trae consigo la librería Nimbus JOSE + JWT. Esta librería se encarga de firmar y verificar la firma del token. No necesitas agregar jjwt ni ninguna otra librería JWT.

3. Configurar la clave secreta

Por simplicidad, firmaremos el token con el algoritmo simétrico HS256. Se usa la misma clave secreta tanto para firmar como para verificar la firma.

jwt.secret=${JWT_SECRET:reemplaza-esto-con-una-clave-secreta-de-al-menos-32-bytes}
jwt.expiration-minutes=15

La expresión ${JWT_SECRET:...} lee la clave de la variable de entorno JWT_SECRET. Si esta variable no existe, Spring usa el valor predeterminado después de los dos puntos. El valor predeterminado es solo para el entorno de desarrollo.

Nota: HS256 requiere una clave de al menos 256 bits, es decir, 32 bytes. Si la clave es más corta, Nimbus lanzará KeyLengthException la primera vez que firme el token.

4. Configurar Spring Security

4.1. JwtEncoder, JwtDecoder y usuarios

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Value("${jwt.secret}")
    private String secret;

    @Bean
    SecretKey jwtSecretKey() {
        return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }

    @Bean
    JwtEncoder jwtEncoder(SecretKey key) {
        return new NimbusJwtEncoder(new ImmutableSecret<>(key));
    }

    @Bean
    JwtDecoder jwtDecoder(SecretKey key) {
        return NimbusJwtDecoder.withSecretKey(key)
                .macAlgorithm(MacAlgorithm.HS256)
                .build();
    }

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

    @Bean
    UserDetailsService userDetailsService(PasswordEncoder encoder) {
        var customer = User.withUsername("alice")
                .password(encoder.encode("alice123")).roles("CUSTOMER").build();
        var admin = User.withUsername("bob")
                .password(encoder.encode("bob123")).roles("CUSTOMER", "ADMIN").build();
        return new InMemoryUserDetailsManager(customer, admin);
    }

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

El rol de cada bean:

  • SecretKey envuelve la cadena secreta en una clave HMAC-SHA256.
  • JwtEncoder usa esta clave para firmar el token cuando el usuario inicia sesión.
  • JwtDecoder verifica la firma y la expiración (exp) del token en cada solicitud. Resource Server usa automáticamente este bean.
  • InMemoryUserDetailsManager crea dos usuarios de ejemplo. En producción, reemplazarías esto con una implementación que lea de la base de datos.
  • AuthenticationManager verifica el nombre de usuario y la contraseña en el endpoint de inicio de sesión.

4.2. SecurityFilterChain

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
    return http.build();
}

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    var authorities = new JwtGrantedAuthoritiesConverter();
    authorities.setAuthoritiesClaimName("roles");
    authorities.setAuthorityPrefix("");

    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authorities);
    return converter;
}

Este bean está en la misma clase SecurityConfig. Cada parte hace lo siguiente:

  • csrf.disable(): la API no usa cookies para autenticación, por lo que no es susceptible a ataques CSRF.
  • STATELESS: Spring no crea HttpSession. Cada solicitud se autentica nuevamente desde el token.
  • authorizeHttpRequests: el endpoint de inicio de sesión es público. /api/admin/** es solo para el rol ADMIN. Todos los demás endpoints requieren autenticación.
  • oauth2ResourceServer(...jwt...): habilita el filtro que lee el encabezado Authorization: Bearer <token> y decodifica el token con JwtDecoder.

Por defecto, Spring lee los permisos del claim scope y agrega el prefijo SCOPE_. Como queremos usar hasRole("ADMIN"), JwtAuthenticationConverter se reconfigura para leer el claim roles. El prefijo se omite porque los valores en el token ya tienen la forma ROLE_ADMIN.

5. Emitir token al iniciar sesión

5.1. TokenService

@Service
public class TokenService {

    private final JwtEncoder encoder;
    private final long expirationMinutes;

    public TokenService(JwtEncoder encoder,
                        @Value("${jwt.expiration-minutes}") long expirationMinutes) {
        this.encoder = encoder;
        this.expirationMinutes = expirationMinutes;
    }

    public String generateToken(Authentication authentication) {
        Instant now = Instant.now();
        List<String> roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("nonla-shop")
                .issuedAt(now)
                .expiresAt(now.plus(expirationMinutes, ChronoUnit.MINUTES))
                .subject(authentication.getName())
                .claim("roles", roles)
                .build();

        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }

    public long getExpirationSeconds() {
        return expirationMinutes * 60;
    }
}

El token contiene los siguientes claims: emisor (iss), tiempo de emisión (iat), tiempo de expiración (exp), nombre de usuario (sub) y lista de roles.

La línea JwsHeader.with(MacAlgorithm.HS256) es obligatoria. Si falta, NimbusJwtEncoder elige RS256 por defecto. Luego no encuentra una clave apropiada y lanza el error Failed to select a JWK signing key.

5.2. AuthController

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final TokenService tokenService;

    public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService;
    }

    public record LoginRequest(String username, String password) {}
    public record TokenResponse(String accessToken, String tokenType, long expiresIn) {}

    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest request) {
        Authentication auth = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        request.username(), request.password()));
        String token = tokenService.generateToken(auth);
        return new TokenResponse(token, "Bearer", tokenService.getExpirationSeconds());
    }
}

authenticate() verifica el nombre de usuario y la contraseña a través de DaoAuthenticationProvider. Si son incorrectos, el método lanza BadCredentialsException. Spring Security traduce esto a HTTP 401. Si son correctos, el controlador crea el token y lo devuelve al cliente.

Pruébalo con curl:

curl -s -X POST localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"alice123"}'
{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJub25sYS1zaG9wIiwic3ViIjoiYWxpY2UiLC...","tokenType":"Bearer","expiresIn":900}

6. Endpoints protegidos

@RestController
@RequestMapping("/api")
public class OrderController {

    public record Order(long id, String customer, BigDecimal total) {}

    private final List<Order> orders = List.of(
            new Order(1, "alice", new BigDecimal("250000")),
            new Order(2, "bob", new BigDecimal("990000")));

    @GetMapping("/orders")
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return orders.stream()
                .filter(o -> o.customer().equals(jwt.getSubject()))
                .toList();
    }

    @GetMapping("/admin/orders")
    public List<Order> allOrders() {
        return orders;
    }
}

@AuthenticationPrincipal Jwt jwt inyecta el token autenticado en el controlador. Obtienes el nombre de usuario con jwt.getSubject(). Para leer otros claims, usa jwt.getClaim("roles").

Prueba con tres casos diferentes:

# Sin token
curl -i localhost:8080/api/orders

# alice con token válido
curl -s localhost:8080/api/orders -H "Authorization: Bearer $TOKEN"

# alice llama al endpoint de administrador
curl -i localhost:8080/api/admin/orders -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401
WWW-Authenticate: Bearer

[{"id":1,"customer":"alice","total":250000}]

HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", ...

El resultado muestra dos códigos de error diferentes. 401 significa no autenticado: falta token, firma de token incorrecta o token expirado. 403 significa autenticado pero sin permisos suficientes.

7. Escribir pruebas

spring-security-test proporciona un post-processor jwt(). Simula un JWT autenticado sin necesidad de firmar un token real. Lo combinamos con una prueba end-to-end que tiene un inicio de sesión real.

@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    void whenNoToken_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/orders")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenLoginAndCallWithToken_thenReturnOwnOrders() throws Exception {
        String body = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"username":"alice","password":"alice123"}
                                """))
                .andReturn().getResponse().getContentAsString();
        String token = objectMapper.readTree(body).get("accessToken").asText();

        MvcResult result = mockMvc.perform(get("/api/orders")
                        .header("Authorization", "Bearer " + token))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(200);
        assertThat(result.getResponse().getContentAsString())
                .contains("\"customer\":\"alice\"")
                .doesNotContain("\"customer\":\"bob\"");
    }

    @Test
    void whenWrongPassword_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"username\":\"alice\",\"password\":\"sai\"}"))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenCustomerCallsAdminEndpoint_thenForbidden() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/admin/orders")
                        .with(jwt().jwt(j -> j.subject("alice"))
                                .authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(403);
    }
}

Los static imports necesarios son MockMvcRequestBuilders.*, SecurityMockMvcRequestPostProcessors.jwt y Assertions.assertThat de AssertJ.

La segunda prueba verifica todo el flujo. Llama al endpoint de inicio de sesión, obtiene el token, luego usa el token para llamar a la API. Esta prueba detecta errores de configuración del encoder y decoder, como olvidar JwsHeader o una clave demasiado corta.

La última prueba usa jwt() para verificar solo la parte de autorización. Ten en cuenta que .authorities(...) sobrescribe el converter, así que debes pasar el nombre correcto de authority ROLE_CUSTOMER.

8. Errores comunes y cuándo no usar

Errores comunes:

  • Clave demasiado corta: una clave menor a 32 bytes causa KeyLengthException con HS256.
  • Olvidar header HS256: JwtEncoder reporta Failed to select a JWK signing key.
  • Prefijo de authority incorrecto: con el converter predeterminado, los permisos tienen la forma SCOPE_.... Luego hasRole("ADMIN") siempre devuelve 403. Verifica que el claim y el prefijo coincidan.
  • Hardcodear el secreto en el repositorio: siempre lee el secreto de una variable de entorno o un gestor de secretos.
  • Incluir datos sensibles en el payload: el payload JWT solo se codifica en Base64URL, no se encripta. Cualquiera con el token puede leer su contenido. No pongas contraseñas, números de tarjeta o información personal en el token.

Nota: No es posible revocar un JWT antes de que expire si no agregas un mecanismo secundario como una blacklist. Mantén el access token de corta duración (5–15 minutos). Si necesitas sesiones largas, usa un refresh token almacenado en el servidor para poder revocarlo.

Cuándo no usar este enfoque:

  • Aplicación web renderizada en el servidor, como Thymeleaf: sesiones con cookies son más simples y seguras.
  • Necesitas cerrar sesión o bloquear una cuenta de inmediato: sesiones u opaque tokens con introspection son más apropiados.
  • Sistema de múltiples servicios: compartir una clave HMAC entre todos los servicios es muy arriesgado. En ese caso, deberías usar un servidor de autorización separado, como Spring Authorization Server o Keycloak. El servidor de autorización firma tokens con RSA o EC, y cada servicio solo necesita configurar spring.security.oauth2.resourceserver.jwt.issuer-uri para obtener la clave pública a través de JWKS.

9. Conclusión

Este artículo ha construido una API REST sin estado protegida con JWT, que incluye los siguientes pasos:

  • Usar NimbusJwtEncoder y NimbusJwtDecoder con una clave HS256.
  • Emitir tokens en el endpoint /api/auth/login a través de AuthenticationManager.
  • Permitir que OAuth2 Resource Server autentifique automáticamente el encabezado Bearer.
  • Mapear el claim roles a authority para usar hasRole.
  • Probar los casos 401, 403 y el flujo exitoso con MockMvc y AssertJ.

A partir de esta base, puedes reemplazar InMemoryUserDetailsManager con usuarios almacenados en la base de datos, agregar refresh tokens, o cambiar a claves asimétricas cuando el sistema se expanda a múltiples servicios.

spring-bootspring-securityjwtrest-apijava