BACKEND 14 min

Securing REST APIs with Spring Boot 3 and Spring Security 6: OAuth2 Resource Server and JWT

Implement modern API security in Spring Boot 3 with Spring Security 6: configure an OAuth2 Resource Server, asymmetric JWT verification via JWKS, and fine-grained SpEL authorization.

Written by Samir El Azem Crovetto • Senior Backend Engineer & DevSecOps

Enterprise API Security: Moving from Shared Secrets to OAuth2

In early distributed Java microservice architectures, developers frequently protected REST endpoints by writing custom servlet filters that signed and validated JSON Web Tokens (JWT) using third-party utility libraries (such as JJWT or Auth0) paired with a shared symmetric secret key (HMAC-SHA256).

In modern enterprise architectures, this legacy pattern represents an unacceptable security risk.

Symmetric key signing (HS256) requires distributing the identical shared secret across every microservice that needs to validate inbound requests. If an adversary compromises even a single peripheral service, they can extract the secret key and forge cryptographically valid administrator tokens for every other service across the internal network, completely undermining the principle of least privilege.

With the release of Spring Boot 3 and Spring Security 6, the enterprise Java ecosystem has standardized around the OAuth 2.1 and OpenID Connect specifications: backend microservices must be structured as dedicated OAuth2 Resource Servers. In this model, individual microservices neither issue credentials nor maintain shared secrets; they delegate authentication entirely to an authoritative Identity Provider (IdP) and verify asymmetric cryptographic signatures (RS256 or ES256) by dynamically retrieving public keys from a JWKS (JSON Web Key Set) endpoint.

This technical guide covers the end-to-end implementation of an OAuth2 Resource Server in Spring Boot 3 and Spring Security 6: from the functional configuration of SecurityFilterChain and asymmetric token verification with JWKS, to custom enterprise role mapping, fine-grained method security with @PreAuthorize, and strict token audience validation.


1. Asymmetric Verification Architecture with JWKS

To understand why the Resource Server pattern is superior to shared symmetric secrets, examine the request and verification flow between the client, the Identity Provider (such as Keycloak, Microsoft Entra ID, or Okta), and the microservice:

sequenceDiagram
    autonumber
    actor Client as Client (SPA / Mobile / CLI)
    participant IdP as Identity Provider (Keycloak / Entra ID)
    participant API as REST Microservice (Resource Server)

    Client->>IdP: 1. Authenticate (Authorization Code + PKCE)
    IdP-->>Client: 2. Issues Signed JWT Access Token (Signed with Private Key)
    Client->>API: 3. Request GET /api/v1/resources with Authorization: Bearer <JWT>
    critical Cryptographic Token Verification
        API->>IdP: 4. Fetches Public Keys (GET /.well-known/jwks.json - Cached)
        Note over API: Verifies signature with Public Key (matching KID)<br/>Validates expiration (exp, nbf), issuer (iss), and audience (aud)
    end
    alt Valid Token and Authorized Scope
        Note over API: Maps Claims to GrantedAuthorities (Roles and Scopes)
        API-->>Client: 5. 200 OK (Requested JSON Payload)
    else Invalid Token or Missing Role
        API-->>Client: 6. 401 Unauthorized / 403 Forbidden
    end

Security Benefits of Asymmetric Verification:

  1. Cryptographic Boundary Isolation: The private signing key resides strictly within the Identity Provider's Hardware Security Module (HSM) or managed key store. Backend resource servers never touch private keys, making token forgery mathematically impossible even if a microservice container is fully compromised.
  2. Transparent Key Rollover: Identity Providers expose multiple active public keys within their JWKS catalog, distinguished by a Key ID (kid) header in each JWT. When cryptographic keys rotate on a scheduled basis, resource servers fetch updated public keys seamlessly without requiring application redeployments.
  3. Decoupled Responsibilities: The microservice is completely liberated from managing user tables, password hashing algorithms, and multi-factor authentication (MFA) workflows; its single responsibility is cryptographic token verification and localized business authorization.

2. Dependencies and Declarative Configuration in Spring Boot 3

To build an OAuth2 Resource Server with Spring Boot 3.2+ and Java 21, include the official Spring Boot starter in pom.xml:

<dependencies>
    <!-- Official Spring Boot Starter for OAuth2 Resource Server -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>

    <!-- Spring Web for REST controllers -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Jakarta Bean Validation -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

Declarative Configuration in application.yml

Spring Boot automates JwtDecoder bean instantiation when an issuer URI or JWK Set URI is declared:

server:
  port: 8080
  shutdown: graceful

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Option 1: Issuer URI (Automated OpenID discovery and JWKS caching)
          issuer-uri: https://auth.secdevoops.es/realms/enterprise-realm
          # Option 2: Explicit JWK Set URI (Used when discovery endpoints are firewalled)
          jwk-set-uri: https://auth.secdevoops.es/realms/enterprise-realm/protocol/openid-connect/certs
          # Enforce modern asymmetric signature algorithms
          jws-algorithms:
            - RS256
            - ES256

By providing issuer-uri, Spring Boot initializes an internal Nimbus-based JwtDecoder that caches public keys, validates signatures, and verifies that the incoming token's iss claim exactly matches the configured authority.


3. Modern Security Filter Chain (SecurityFilterChain)

In Spring Security 6, the deprecated WebSecurityConfigurerAdapter has been completely eliminated. Security configurations are defined as @Bean definitions of type SecurityFilterChain utilizing fluent lambda syntax for strict immutability.

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.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) // Activates @PreAuthorize and @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. Disable CSRF for purely stateless REST APIs
            .csrf(AbstractHttpConfigurer::disable)
            
            // 2. Enforce strict CORS configuration
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            
            // 3. Stateless session policy: No HTTP sessions created or persisted
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            
            // 4. Perimeter request authorization rules
            .authorizeHttpRequests(auth -> auth
                // Unauthenticated health probes and OpenAPI documentation
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
                // Strict role restrictions at the boundary
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                // All other endpoints require valid bearer token
                .anyRequest().authenticated()
            )
            
            // 5. Connect OAuth2 Resource Server with custom token converter
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))
            )
            
            .build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        // Explicit origins only (wildcard '*' strictly forbidden in production)
        config.setAllowedOrigins(List.of("https://app.secdevoops.es", "https://admin.secdevoops.es"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
        config.setExposedHeaders(List.of("X-Correlation-ID"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L); // 1 hour preflight cache

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

4. Normalizing Roles and Scopes with JwtAuthenticationConverter

By default, Spring Security parses only standard OAuth2 scopes from the scope or scp claims, prefixing them with SCOPE_.

Enterprise Identity Providers, however, structure user roles inside vendor-specific claim hierarchies:

  • Keycloak: Stores roles in nested maps like realmaccess.roles or resourceaccess.{client-id}.roles.
  • Microsoft Entra ID (Azure AD): Stores application roles in a top-level string array named roles.
  • Auth0 / Okta: Utilize custom namespace claims like https://secdevoops.es/roles or groups.

To normalize these structures into Spring's standard GrantedAuthority format (hasRole('ADMIN') evaluates against ROLE_ADMIN), configure a decoupled converter:

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. Extract standard scopes (SCOPE_read, SCOPE_write)
        Collection<GrantedAuthority> authorities = Stream.concat(
            defaultAuthoritiesConverter.convert(jwt).stream(),
            extractEnterpriseRoles(jwt).stream()
        ).collect(Collectors.toSet());

        // 2. Identify principal name (preferred_username or sub)
        String principalName = jwt.hasClaim("preferred_username") 
            ? jwt.getClaimAsString("preferred_username") 
            : jwt.getSubject();

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

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

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

        // Case B: Keycloak ('realm_access.roles' claim)
        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);
            }
        }

        // Map every role to Spring's standard ROLE_ prefix
        return roles.stream()
            .map(roleName -> {
                String normalized = roleName.toUpperCase();
                return normalized.startsWith("ROLE_") 
                    ? new SimpleGrantedAuthority(normalized) 
                    : new SimpleGrantedAuthority("ROLE_" + normalized);
            })
            .collect(Collectors.toSet());
    }
}

5. Fine-Grained Authorization with SpEL and @PreAuthorize

With claims normalized into granted authorities, authorization logic should be enforced close to business domain boundaries using Spring Method Security.

Controller and Service Implementation

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;
    }

    // Role-Based Access Control (RBAC)
    @GetMapping
    @PreAuthorize("hasAnyRole('ADMIN', 'AUDITOR') or hasAuthority('SCOPE_orders:read')")
    public ResponseEntity<?> listAllOrders() {
        return ResponseEntity.ok(orderService.findAll());
    }

    // Attribute-Based Access Control (ABAC) using custom SpEL bean evaluation
    @GetMapping("/{orderId}")
    @PreAuthorize("hasRole('ADMIN') or @orderSecurityValidator.isOrderOwner(#orderId, authentication.name)")
    public ResponseEntity<OrderResponseDto> getOrderDetails(@PathVariable Long orderId) {
        return ResponseEntity.ok(orderService.findById(orderId));
    }

    // Contextual claim extraction from validated JWT principal
    @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));
    }
}

Domain Security Validator Component:

Evaluating @orderSecurityValidator via SpEL decouples complex relational checks from controller method signatures:

@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: deny access if record does not exist
    }
}

6. Strict Audience Validation and Token Confusion Prevention

A frequent architectural vulnerability in microservice fabrics is the token replay attack (Token Substitution).

If a client receives a legitimate token intended for a low-privilege service (such as a public feedback API), but a high-value payment service validates only the signature of the Identity Provider without verifying the Audience (aud) claim, an adversary can forward the feedback token to execute unauthorized payment operations.

To prevent token reuse across distinct services, OAuth 2.1 requires every Resource Server to enforce that its explicit client identifier exists in the token's aud list.

Implementing a Custom OAuth2TokenValidator:

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);

        // Standard issuer validator
        OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);

        // Custom audience validator
        OAuth2TokenValidator<Jwt> audienceValidator = token -> {
            List<String> audiences = token.getAudience();
            if (audiences != null && audiences.contains(expectedAudience)) {
                return OAuth2TokenValidatorResult.success();
            }
            OAuth2Error error = new OAuth2Error(
                "invalid_token", 
                "Token audience mismatch: expected " + expectedAudience, 
                null
            );
            return OAuth2TokenValidatorResult.failure(error);
        };

        // Combine validators: requires legitimate issuer AND expected audience
        OAuth2TokenValidator<Jwt> combinedValidator = new DelegatingOAuth2TokenValidator<>(
            withIssuer, 
            audienceValidator
        );

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

7. Automated Security Testing for Protected APIs

Disabling security filters during integration tests using @AutoConfigureMockMvc(addFilters = false) is an anti-pattern that hides authorization regressions in CI/CD pipelines.

Spring Security Test provides native support for synthesizing validated JWT tokens without spinning up external IdPs:

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 requestWithoutToken_ShouldReturn401Unauthorized() throws Exception {
        mockMvc.perform(get("/api/v1/orders"))
            .andExpect(status().isUnauthorized());
    }

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

    @Test
    void adminRole_CanAccessGlobalOrderList_Returns200Ok() 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. Production Checklist: OAuth2 Resource Server

Before deploying Spring Boot 3 microservices to production, verify compliance with the following security baseline:

Security Control Implementation Threat Mitigated
Asymmetric Cryptography RS256 / ES256 via JWKS Eliminates shared symmetric secret exposure across microservices.
Issuer Validation (iss) JwtValidators.createDefaultWithIssuer() Prevents acceptance of tokens from rogue identity providers.
Audience Validation (aud) Custom OAuth2TokenValidator Prevents token reuse across distinct API boundaries.
Stateless Sessions SessionCreationPolicy.STATELESS Eliminates session hijacking and ensures horizontal scaling.
Strict CORS Policy Explicit whitelisted origins Prevents cross-origin browser credential theft.
Role Normalization JwtAuthenticationConverter to ROLE_ Ensures consistent authorization evaluation in @PreAuthorize.
CI/CD Security Tests Tests using SecurityMockMvcRequestPostProcessors.jwt() Validates access control policies on every commit.

Conclusion

Transitioning REST APIs to the OAuth2 Resource Server architectural pattern in Spring Boot 3 resolves the structural vulnerabilities of shared symmetric secrets and manual filter chains. Asymmetric token verification backed by dynamic JWKS endpoints establishes a scalable, cryptographically verifiable security baseline aligned with Zero Trust.

Pairing this identity architecture with Spring Data JPA High-Performance Persistence, Docker Security Hardening, and continuous auditing in DevSecOps Pipelines ensures robust protection for enterprise microservices.

If you need an architectural review, token validation audit, or assistance migrating your enterprise APIs to OAuth2 Resource Server with Spring Boot 3, explore my Cybersecurity Consulting Services or reach out directly via About Me.