OAuth2 Resource Server
Use when configuring Spring Boot as an OAuth2 resource server, validating JWTs from an external auth provider (Keycloak, Auth0, Okta, Cognito), extracting claims, or implementing scope-based authorization.
View SKILL.mdSpring Boot 4.x ships Spring Security 7 — lambda DSL only; and(), authorizeRequests(),
antMatchers(), and AntPathRequestMatcher/MvcRequestMatcher are gone
(requestMatchers("/path/**") is backed by PathPatternRequestMatcher).
Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/v1/admin/**").hasAuthority("SCOPE_admin")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))
)
.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
// Keep the defaults: the "scope" OR "scp" claim (Okta/Entra use scp) -> SCOPE_*.
// Calling setAuthoritiesClaimName(...) replaces them — then hasAuthority("SCOPE_admin")
// can never pass. Extend the defaults with Keycloak realm roles instead.
var scopes = new JwtGrantedAuthoritiesConverter();
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> authorities = new ArrayList<>(scopes.convert(jwt));
// Keycloak nests realm roles under realm_access.roles -> ROLE_* (for hasRole)
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess != null && realmAccess.get("roles") instanceof Collection<?> roles) {
roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
}
return authorities;
});
return converter;
}
}
application.yml — Common Providers
Choose the provider configuration matching your issuer; these are alternatives, not duplicate
spring: mappings to paste into one YAML document.
# Keycloak
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/my-realm
jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs
audiences: my-api # without it, tokens minted for ANY client in the realm are accepted
# Auth0
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://your-domain.auth0.com/
audiences: https://your-api.example.com # custom claim validation
Custom Claim Extraction
@Component
public class JwtClaimExtractor {
// Provider subjects are opaque strings ("auth0|64f1c2", Okta "00u1abcd..."), not UUIDs.
public String getSubject(JwtAuthenticationToken token) {
return token.getToken().getSubject();
}
public String getEmail(JwtAuthenticationToken token) {
return token.getToken().getClaimAsString("email");
}
public List<String> getRoles(JwtAuthenticationToken token) {
// Keycloak nests roles under realm_access.roles
Map<String, Object> realmAccess = token.getToken().getClaimAsMap("realm_access");
if (realmAccess == null) return List.of();
return (List<String>) realmAccess.getOrDefault("roles", List.of());
}
}
Controller — Accessing Current User
Use the exact (iss, sub) pair as the external identity key, with a database unique
constraint on that pair. Email and username are optional, mutable profile attributes,
not account lookup or automatic account-linking keys, even when email is verified.
The ExternalIdentity template accepts an already
verified access token; signature, issuer, audience and expiry validation must happen first.
Allow only configured issuers; never discover an arbitrary issuer URL from an untrusted token.
For existing subject-only mappings, backfill the known issuer before introducing a second issuer.
Account linking requires an explicit authenticated flow proving control of both accounts.
@RestController
@RequiredArgsConstructor
public class OrderController {
@GetMapping("/api/v1/orders/my")
public ApiResponse<List<OrderResponse>> myOrders(
@AuthenticationPrincipal Jwt jwt // inject JWT directly
) {
ExternalIdentity identity = ExternalIdentity.from(jwt);
return ApiResponse.ok(orderService.findByOwner(identity.issuer(), identity.subject()));
}
// Or with JwtAuthenticationToken for full principal
@GetMapping("/api/v1/profile")
public ApiResponse<ProfileResponse> profile(JwtAuthenticationToken token) {
ExternalIdentity identity = ExternalIdentity.from(token.getToken());
return ApiResponse.ok(userService.findByExternalIdentity(identity.issuer(), identity.subject()));
}
}
Method Security with Scopes
@PreAuthorize("hasAuthority('SCOPE_orders:read')")
public List<Order> findAll() { ... }
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#orderId, authentication)")
public Order findById(UUID orderId) { ... }
// Custom security bean
@Component("orderSecurity")
public class OrderSecurityService {
public boolean isOwner(UUID orderId, Authentication auth) {
Jwt jwt = (Jwt) auth.getPrincipal();
ExternalIdentity identity = ExternalIdentity.from(jwt);
return orderRepository.existsByIdAndOwnerIssuerAndOwnerSubject(
orderId, identity.issuer(), identity.subject());
}
}
Verification scope
The repository fixture targets Boot 4.1.0 with Java 17/21 and checks signed-token validation and issuer/subject mapping; it does not contact a production identity provider. Check the coverage matrix before treating another minor version or an illustrative snippet as verified.
Gotchas
- Agent finds or merges accounts by email - use the verified issuer/subject pair; email changes must not change ownership.
- Agent calls
setAuthoritiesClaimName(...)onJwtGrantedAuthoritiesConverter— it replaces the defaultscope/scplookup, soSCOPE_*authorities vanish (and Okta/Entrascpscopes are dropped); extend the defaults instead - Agent parses
jwt.getSubject()withUUID.fromString— Auth0 (auth0|...) and Okta (00u...) subjects aren't UUIDs; the request fails with 500 - Agent omits
audiences— the resource server then accepts any token the issuer minted for any client - Agent uses
hasRole("ADMIN")for scope check — scopes usehasAuthority("SCOPE_admin") - Agent forgets
issuer-urivalidation — always configure to prevent token forgery - Agent maps roles wrong for Keycloak — roles are nested under
realm_access.roles - Agent uses
getPrincipal()directly — cast toJwtor use@AuthenticationPrincipal Jwt - Agent adds
userDetailsServicebean — not needed for resource servers (stateless JWT) - Agent adds the Boot 3 starter
spring-boot-starter-oauth2-resource-server— Boot 4 renamed security starters; usespring-boot-starter-security-oauth2-resource-server - Agent uses non-lambda chaining (
.oauth2ResourceServer().jwt(),.and()) — removed in Security 7, won't compile; lambda DSL only - Agent writes
antMatchers()/AntPathRequestMatcher— removed in Security 7; userequestMatchers("/path/**")(backed byPathPatternRequestMatcher) - Agent mocks
JwtDecoderwith@MockBeanin tests — removed in Boot 4; use@MockitoBean(and@AutoConfigureMockMvc—@SpringBootTestno longer providesMockMvc)
Examples & templates
bad-resource-server-config.javaexamples
// ❌ BAD — pre-Security-7 API (won't compile), wrong role check, unnecessary UserDetailsService, session-based
@Configuration
@EnableWebSecurity
public class ResourceServerConfig extends WebSecurityConfigurerAdapter { // REMOVED — gone since Security 6, still gone in 7
@Autowired // field injection
private UserDetailsService userDetailsService; // not needed for JWT resource server
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable() // non-lambda DSL — removed in Security 7, won't compile
.authorizeRequests() // removed in Security 7 — use authorizeHttpRequests
.antMatchers("/api/**") // antMatchers removed in Security 7 — use requestMatchers
.hasRole("ADMIN") // hasRole expects ROLE_ prefix, not SCOPE_
.and() // and() removed from HttpSecurity in Security 7
.oauth2ResourceServer() // non-lambda style removed — use lambda DSL
.jwt(); // no custom converter — roles won't map correctly
// Session is not explicitly STATELESS — defaults to IF_REQUIRED
// This means the server creates HTTP sessions, defeating JWT's purpose
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri(
"http://auth-server/.well-known/jwks.json") // hardcoded URL, should use issuer-uri
.build();
}
// UserDetailsService is unnecessary for resource servers —
// the JWT token contains all claims needed for authentication
@Bean
public UserDetailsService userDetailsService() {
return username -> userRepository.findByEmail(username) // extra DB call per request!
.map(u -> User.withUsername(u.getEmail()).password(u.getPassword()).roles("USER").build())
.orElseThrow();
}
}good-resource-server-config.javaexamples
// ✅ GOOD — JWT resource server, custom converter, scope-based auth, stateless
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").hasAuthority("SCOPE_read")
.requestMatchers(HttpMethod.POST, "/api/v1/orders/**").hasAuthority("SCOPE_write")
.requestMatchers("/api/v1/admin/**").hasAuthority("SCOPE_admin")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
// Defaults read the "scope" OR "scp" claim -> SCOPE_*. Setting the claim name to
// "scope" would drop the scp fallback that Okta and Entra ID access tokens use.
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
converter.setPrincipalClaimName("sub");
return converter;
}
}ExternalIdentity.javatemplates
package com.example.config;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.oauth2.jwt.Jwt;
/** Account key from an already verified access token, not a token validator. */
public record ExternalIdentity(String issuer, String subject) {
public ExternalIdentity {
if (issuer == null || issuer.isBlank() || subject == null || subject.isBlank()) {
throw new BadCredentialsException("Issuer and subject are required");
}
}
public static ExternalIdentity from(Jwt jwt) {
// Preserve exact issuer and opaque subject; do not lowercase or trim either.
return new ExternalIdentity(jwt.getClaimAsString("iss"), jwt.getSubject());
}
}JwtClaimExtractor.javatemplates
package com.example.config;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Extracts claims from the current JWT token.
* Inject this into services that need to know the authenticated user.
*/
@Component
public class JwtClaimExtractor {
public ExternalIdentity getIdentity() {
return ExternalIdentity.from(getJwt());
}
// Opaque provider id ("auth0|...", Okta "00u..."): map it to your user record, don't parse it.
public String getSubject() {
return getClaim("sub");
}
public String getEmail() {
return getClaim("email");
}
public String getUsername() {
return getClaim("preferred_username");
}
/**
* Extract roles from standard OAuth2 scope claim.
*/
public List<String> getScopes() {
String scope = getClaim("scope");
return scope != null ? List.of(scope.split(" ")) : List.of();
}
/**
* Extract roles from Keycloak's nested realm_access.roles claim.
* Only needed when using Keycloak as the identity provider.
*/
@SuppressWarnings("unchecked")
public List<String> getKeycloakRoles() {
Jwt jwt = getJwt();
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess == null) return Collections.emptyList();
Object roles = realmAccess.get("roles");
return roles instanceof List ? (List<String>) roles : Collections.emptyList();
}
private String getClaim(String claimName) {
return getJwt().getClaimAsString(claimName);
}
private Jwt getJwt() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| !(authentication.getPrincipal() instanceof Jwt jwt)) {
throw new AuthenticationCredentialsNotFoundException("Authenticated JWT required");
}
return jwt;
}
}ResourceServerConfig.javatemplates
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
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
public class ResourceServerConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/public/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
// Defaults read the "scope" OR "scp" claim -> SCOPE_*. Setting the claim name to
// "scope" would drop the scp fallback that Okta and Entra ID access tokens use.
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
converter.setPrincipalClaimName("sub");
return converter;
// For Keycloak: override to extract from realm_access.roles
// See JwtClaimExtractor template for Keycloak-specific role extraction
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000")); // frontend origin
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}Verification scope
- Compiled sources
- Config, extractor and identity, both trees
- Runtime checks
- Signed-token issuer/audience/expiry rejection, missing scopes and email-independent account mapping
Coverage describes checked-in fixtures, not a guarantee for every snippet or project.
Coverage matrix & agent evidenceADD TO YOUR AGENTInstall Boot 4 skills
Install Boot 4 skills
claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-4-skills@spring-boot-skillscodex plugin marketplace add rrezartprebreza/spring-boot-skills
codex plugin add spring-boot-4-skills@spring-boot-skills