BACKEND 14 min

Seguridad en APIs REST con Spring Boot 3 y Spring Security 6: OAuth2 Resource Server y JWT

Implementa una arquitectura de seguridad moderna en Spring Boot 3 con Spring Security 6: configuración de OAuth2 Resource Server, verificación asimétrica de JWT mediante JWKS y control de acceso fino con SpEL.

Escrito por Samir El Azem Crovetto • Senior Backend Engineer & DevSecOps

La Madurez de la Seguridad en APIs: De Secretos Compartidos a OAuth2

En las etapas iniciales del desarrollo de microservicios en Java, la solución recurrente para proteger endpoints REST consistía en implementar filtros manuales que firmaban y validaban tokens JSON Web Tokens (JWT) utilizando librerías de terceros (como JJWT o Auth0) y una clave secreta simétrica compartida (HMAC-SHA256).

En arquitecturas corporativas distribuidas, este enfoque constituye un grave riesgo de seguridad.

El uso de claves simétricas (HS256) exige distribuir la misma clave secreta compartida entre todos los microservicios que necesiten validar peticiones. Si un único microservicio periférico se ve comprometido, el atacante puede extraer el secreto y falsificar tokens legítimos con privilegios de administrador para cualquier otro servicio de la red interna, anulando por completo el principio de menor privilegio.

Con la llegada de Spring Boot 3 y Spring Security 6, el ecosistema Java ha estandarizado la arquitectura recomendada por los estándares OAuth 2.1 y OpenID Connect: los microservicios deben configurarse formalmente como OAuth2 Resource Servers. En este modelo, el microservicio no emite credenciales ni comparte secretos con nadie; delega la autenticación en un Proveedor de Identidad (IdP) centralizado y verifica las firmas criptográficas asimétricas (RS256 o ES256) descargando dinámicamente las claves públicas desde un endpoint JWKS (JSON Web Key Set).

En esta guía técnica analizamos la implementación completa de un OAuth2 Resource Server en Spring Boot 3 y Spring Security 6: desde la configuración reactiva de la cadena de filtros SecurityFilterChain y la validación asimétrica de tokens mediante JWKS, hasta la extracción personalizada de roles, el control de acceso fino con @PreAuthorize y la validación estricta de audiencias y emisores.


1. Arquitectura de Verificación Asimétrica con JWKS

Para comprender la robustez del modelo Resource Server frente al esquema de secretos compartidos, analicemos el flujo de interacción entre el cliente, el Proveedor de Identidad (como Keycloak, Microsoft Entra ID u Okta) y el microservicio:

sequenceDiagram
    autonumber
    actor Cliente as Cliente (SPA / Móvil / CLI)
    participant IdP as Proveedor de Identidad (Keycloak / Entra ID)
    participant API as Microservicio REST (Resource Server)

    Cliente->>IdP: 1. Autenticación (Authorization Code + PKCE)
    IdP-->>Cliente: 2. Emite Access Token JWT (Firmado con Clave Privada)
    Cliente->>API: 3. Petición GET /api/v1/recursos con Authorization: Bearer <JWT>
    critical Verificación Criptográfica del Token
        API->>IdP: 4. Descarga Claves Públicas (GET /.well-known/jwks.json - Cached)
        Note over API: Verifica firma con Clave Pública (KID coincidente)<br/>Valida vigencia (exp, nbf), emisor (iss) y audiencia (aud)
    end
    alt Token Válido y Autorizado
        Note over API: Mapea Claims a GrantedAuthorities (Roles y Scopes)
        API-->>Cliente: 5. 200 OK (Payload JSON solicitado)
    else Token Inválido o Permiso Denegado
        API-->>Cliente: 6. 401 Unauthorized / 403 Forbidden
    end

Ventajas de Seguridad de la Criptografía Asimétrica:

  1. Aislamiento Criptográfico Estricto: La clave privada de firma reside exclusivamente protegida dentro del Hardware Security Module (HSM) o almacén seguro del Proveedor de Identidad. Ningún microservicio backend tiene acceso a la clave privada, lo que hace matemáticamente imposible que un microservicio comprometido pueda falsificar tokens.
  2. *Rotación Transparente de Claves (Key Rollover): Los IdPs publican múltiples claves públicas en su conjunto JWKS, identificadas por un parámetro kid (Key ID*) en la cabecera del JWT. Cuando el IdP rota sus claves criptográficas periódicamente, el Resource Server descarga automáticamente la nueva clave pública sin requerir reinicios de la aplicación ni despliegues de configuración.
  3. Desacoplamiento de Responsabilidades: El microservicio no gestiona tablas de usuarios, políticas de contraseñas ni factores de autenticación múltiple (MFA); su única responsabilidad es validar la integridad del token y aplicar las reglas de autorización locales.

2. Dependencias y Configuración Declarativa en Spring Boot 3

Para implementar este estándar en Spring Boot 3.2+ con Java 21, añadimos el starter oficial de OAuth2 Resource Server en pom.xml:

<dependencies>
    <!-- Starter oficial de Spring Boot para Resource Server -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>

    <!-- Starter web para endpoints REST -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Starter de validación Bean Validation (Jakarta) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

Configuración en application.yml

Spring Boot permite configurar la URL del emisor o del conjunto de claves JWKS de forma totalmente declarativa:

server:
  port: 8080
  shutdown: graceful

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Opción 1: Issuer URI (Descarga automática de OpenID Configuration y JWKS)
          issuer-uri: https://auth.secdevoops.es/realms/enterprise-realm
          # Opción 2: JWK Set URI explícito (útil si el IdP no expone discovery público)
          jwk-set-uri: https://auth.secdevoops.es/realms/enterprise-realm/protocol/openid-connect/certs
          # Algoritmos de firma permitidos (impedir degradación a algoritmos débiles)
          jws-algorithms:
            - RS256
            - ES256

Al especificar issuer-uri, Spring Boot inicializa automáticamente un bean de tipo JwtDecoder configurado para validar la firma criptográfica contra el endpoint JWKS del proveedor y verificar que el claim iss del token coincida exactamente con la URI especificada.


3. Cadena de Filtros de Seguridad Moderna (SecurityFilterChain)

En Spring Security 6, la clase histórica WebSecurityConfigurerAdapter ha desaparecido. La configuración se articula mediante beans de tipo SecurityFilterChain utilizando la sintaxis de expresiones lambda fluidas, que garantiza inmutabilidad y legibilidad.

package es.secdevoops.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // Habilita @PreAuthorize y @PostAuthorize
public class SecurityConfig {

    private final Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter;

    public SecurityConfig(Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter) {
        this.jwtAuthenticationConverter = jwtAuthenticationConverter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            // 1. Deshabilitar CSRF en APIs REST puras y sin estado (Stateless)
            .csrf(AbstractHttpConfigurer::disable)
            
            // 2. Configurar CORS explícito
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            
            // 3. Política de sesión STATELESS: Prohibir almacenamiento de sesiones HTTP
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            
            // 4. Reglas de autorización en la capa de red
            .authorizeHttpRequests(auth -> auth
                // Endpoints públicos de diagnóstico y documentación
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
                // Endpoints protegidos por roles globales en capa perimetral
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                // Todo el resto de la API exige autenticación válida
                .anyRequest().authenticated()
            )
            
            // 5. Configurar el Resource Server con el conversor personalizado de JWT
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))
            )
            
            .build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        // Orígenes explícitos permitidos (prohibido wildcard '*' en producción)
        configuration.setAllowedOrigins(List.of("https://app.secdevoops.es", "https://admin.secdevoops.es"));
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
        configuration.setExposedHeaders(List.of("X-Correlation-ID"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L); // 1 hora de caché preflight

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);
        return source;
    }
}

4. Extracción de Roles y Permisos con JwtAuthenticationConverter

Por defecto, Spring Security extrae únicamente los permisos (scopes) contenidos en el claim scope o scp del JWT, prefijándolos con SCOPE_.

Sin embargo, los Proveedores de Identidad corporativos estructuran los roles de usuario en estructuras anidadas propietarias:

  • Keycloak: Almacena los roles en realmaccess.roles o resourceaccess.{client-id}.roles.
  • Microsoft Entra ID (Azure AD): Almacena los roles de aplicación en el array de strings roles.
  • Auth0 / Okta: Emplean claims personalizados como https://secdevoops.es/roles o groups.

Para unificar estos claims en objetos GrantedAuthority reconocibles por las anotaciones de Spring (hasRole('ADMIN') espera una autoridad ROLE_ADMIN), implementamos un conversor desacoplado:

package es.secdevoops.security.jwt;

import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Component
public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtGrantedAuthoritiesConverter defaultAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        // 1. Extraer los Scopes estándar (SCOPE_read, SCOPE_write)
        Collection<GrantedAuthority> authorities = Stream.concat(
            defaultAuthoritiesConverter.convert(jwt).stream(),
            extractEnterpriseRoles(jwt).stream()
        ).collect(Collectors.toSet());

        // 2. Determinar el identificador principal (email o sub)
        String principalClaimName = jwt.hasClaim("preferred_username") 
            ? jwt.getClaimAsString("preferred_username") 
            : jwt.getSubject();

        return new JwtAuthenticationToken(jwt, authorities, principalClaimName);
    }

    @SuppressWarnings("unchecked")
    private Collection<GrantedAuthority> extractEnterpriseRoles(Jwt jwt) {
        Set<String> roles = new HashSet<>();

        // Caso A: Extraer roles de Azure AD / Entra ID (claim 'roles')
        if (jwt.hasClaim("roles")) {
            List<String> azureRoles = jwt.getClaimAsStringList("roles");
            if (azureRoles != null) {
                roles.addAll(azureRoles);
            }
        }

        // Caso B: Extraer roles de Keycloak (claim 'realm_access.roles')
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess != null && realmAccess.containsKey("roles")) {
            List<String> keycloakRoles = (List<String>) realmAccess.get("roles");
            if (keycloakRoles != null) {
                roles.addAll(keycloakRoles);
            }
        }

        // Mapear cada rol al formato estándar de Spring con prefijo ROLE_
        return roles.stream()
            .map(roleName -> {
                String normalized = roleName.toUpperCase();
                return normalized.startsWith("ROLE_") 
                    ? new SimpleGrantedAuthority(normalized) 
                    : new SimpleGrantedAuthority("ROLE_" + normalized);
            })
            .collect(Collectors.toSet());
    }
}

5. Autorización Fina con SpEL y @PreAuthorize

Una vez normalizadas las autoridades en el contexto de seguridad, la autorización en Spring Boot debe aplicarse lo más cerca posible de la lógica de negocio mediante Spring Method Security.

Habilitación y Uso en Controladores y Servicios

package es.secdevoops.api.controller;

import es.secdevoops.api.dto.OrderRequestDto;
import es.secdevoops.api.dto.OrderResponseDto;
import es.secdevoops.service.OrderService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;

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

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    // Autorización basada en Roles (RBAC)
    @GetMapping
    @PreAuthorize("hasAnyRole('ADMIN', 'AUDITOR') or hasAuthority('SCOPE_orders:read')")
    public ResponseEntity<?> listAllOrders() {
        return ResponseEntity.ok(orderService.findAll());
    }

    // Autorización basada en Atributos (ABAC) con expresión SpEL que valida propiedad del recurso
    @GetMapping("/{orderId}")
    @PreAuthorize("hasRole('ADMIN') or @orderSecurityValidator.isOrderOwner(#orderId, authentication.name)")
    public ResponseEntity<OrderResponseDto> getOrderDetails(@PathVariable Long orderId) {
        return ResponseEntity.ok(orderService.findById(orderId));
    }

    // Inyección directa del JWT validado para obtener claims contextuales
    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_orders:write')")
    public ResponseEntity<OrderResponseDto> createOrder(
        @RequestBody OrderRequestDto request,
        @AuthenticationPrincipal Jwt jwt
    ) {
        String tenantId = jwt.getClaimAsString("tenant_id");
        String userEmail = jwt.getClaimAsString("email");
        return ResponseEntity.ok(orderService.create(request, tenantId, userEmail));
    }
}

Componente Validador de Seguridad de Dominio:

El uso del objeto @orderSecurityValidator en SpEL desacopla las reglas complejas de base de datos de las anotaciones estáticas:

@Component("orderSecurityValidator")
public class OrderSecurityValidator {

    private final OrderRepository orderRepository;

    public OrderSecurityValidator(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true)
    public boolean isOrderOwner(Long orderId, String username) {
        return orderRepository.findById(orderId)
            .map(order -> order.getOwnerUsername().equalsIgnoreCase(username))
            .orElse(false); // Fail-Closed: si no existe la orden, denegar acceso
    }
}

6. Validación Estricta de Audience y Prevención de Confusión de Tokens

Uno de los vectores de ataque más habituales en arquitecturas de microservicios es el ataque por reenvío de token (Token Replay / Token Substitution).

Si un usuario obtiene un token legítimo emitido para interactuar con un servicio de baja criticidad (como una API pública de encuestas), pero el microservicio de pagos o facturación se limita a verificar la firma criptográfica del IdP sin validar la audiencia (aud), el atacante puede reenviar ese token para ejecutar operaciones privilegiadas en la API de pagos.

Para evitar este riesgo, el estándar OAuth 2.1 exige que cada Resource Server valide que su propio identificador de cliente figure explícitamente en el claim aud.

Implementación de un OAuth2TokenValidator Personalizado:

package es.secdevoops.security.jwt;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.*;

import java.util.List;

@Configuration
public class JwtValidatorConfig {

    @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
    private String issuerUri;

    @Value("${app.security.expected-audience:secdevoops-api}")
    private String expectedAudience;

    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuerUri);

        // Validador de emisor estándar
        OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);

        // Validador de audiencia personalizado
        OAuth2TokenValidator<Jwt> audienceValidator = token -> {
            List<String> audiences = token.getAudience();
            if (audiences != null && audiences.contains(expectedAudience)) {
                return OAuth2TokenValidatorResult.success();
            }
            OAuth2Error error = new OAuth2Error(
                "invalid_token", 
                "El token no está destinado a esta audiencia (" + expectedAudience + ")", 
                null
            );
            return OAuth2TokenValidatorResult.failure(error);
        };

        // Delegador combinado: exige emisor legítimo Y audiencia esperada
        OAuth2TokenValidator<Jwt> combinedValidator = new DelegatingOAuth2TokenValidator<>(
            withIssuer, 
            audienceValidator
        );

        jwtDecoder.setJwtValidator(combinedValidator);
        return jwtDecoder;
    }
}

7. Estrategia de Testing Automatizado para APIs Protegidas

Un error frecuente al asegurar servicios con OAuth2 consiste en deshabilitar la seguridad en los tests unitarios o de integración mediante @AutoConfigureMockMvc(addFilters = false). Esta práctica es un grave antipatrón, ya que impide verificar regresiones de autorización en el pipeline de CI/CD.

Spring Security Test proporciona utilidades específicas para simular tokens JWT de forma limpia y sin levantar un servidor de identidad real:

package es.secdevoops.api.controller;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void peticionSinToken_DebeRetornar401Unauthorized() throws Exception {
        mockMvc.perform(get("/api/v1/orders"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void usuarioConRolUser_NoDebeAccederAListadoGlobal_Retorna403Forbidden() throws Exception {
        mockMvc.perform(get("/api/v1/orders")
            .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
            .andExpect(status().isForbidden());
    }

    @Test
    void usuarioConRolAdmin_DebeAccederExitosamente_Retorna200Ok() throws Exception {
        mockMvc.perform(get("/api/v1/orders")
            .with(jwt()
                .authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
                .jwt(builder -> builder.claim("preferred_username", "admin.secdevoops"))))
            .andExpect(status().isOk());
    }
}

8. Checklist de Producción: OAuth2 Resource Server

Antes de desplegar una API REST basada en Spring Boot 3 y Spring Security 6 a un entorno de producción, verifica que se cumplan las siguientes directrices arquitectónicas:

Control de Seguridad Configuración / Implementación Objetivo Defensivo
Criptografía Asimétrica RS256 / ES256 vía JWKS Erradica la distribución de secretos compartidos entre microservicios.
Validación de Issuer (iss) JwtValidators.createDefaultWithIssuer() Previene el consumo de tokens emitidos por proveedores no autorizados.
Validación de Audience (aud) OAuth2TokenValidator específico Bloquea ataques de sustitución y reenvío de tokens entre diferentes servicios.
Sesiones Stateless SessionCreationPolicy.STATELESS Previene ataques de fijación de sesión y asegura escalabilidad horizontal.
CORS Endurecido Orígenes explícitos sin comodín * Impide llamadas no autorizadas desde orígenes cruzados en navegadores.
Normalización de Roles JwtAuthenticationConverter con prefijo ROLE_ Permite el uso coherente de @PreAuthorize("hasRole(...)") en la capa de negocio.
Pruebas de Seguridad en CI/CD Tests con SecurityMockMvcRequestPostProcessors.jwt() Garantiza que las reglas de autorización se evalúan en cada compilación.

Conclusión

La modernización de APIs REST en Spring Boot 3 mediante el patrón OAuth2 Resource Server elimina de raíz las vulnerabilidades asociadas a la gestión manual de JWTs y secretos simétricos compartidos. La verificación asimétrica respaldada por endpoints JWKS proporciona una base criptográficamente sólida, escalable y perfectamente alineada con los principios de Zero Trust.

Complementar esta arquitectura con Hardening de Servidores SSH, optimizaciones de persistencia como Spring Data JPA de Alto Rendimiento y pipelines automatizados en DevSecOps permite construir ecosistemas de microservicios robustos y preparados para auditorías corporativas rigurosas.

Si necesitas auditar la arquitectura de autenticación y autorización de tus APIs REST, migrar a OAuth2 Resource Server o implementar modelos Zero Trust en Spring Boot, puedes consultar mis Servicios de Ciberseguridad y Consultoría o ponerte en contacto conmigo a través de Sobre mí.