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:
- 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.
- 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. - 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.rolesorresourceaccess.{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/rolesorgroups.
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.