OAuth2 Resource Server: Protecting APIs with Bearer Tokens
What Is a Resource Server?
In OAuth2, a Resource Server is an API that accepts access tokens and returns protected resources. It doesn’t issue tokens — that’s the Authorization Server’s job. The Resource Server just validates tokens and enforces access control.
flowchart LR
Client[API Client\nor SPA/Mobile] -->|"1. POST /token\n{client_id, secret}"| AS[Authorization Server\nGoogle / Okta / Custom]
AS -->|"2. access_token"| Client
Client -->|"3. GET /api/products\nAuthorization: Bearer {token}"| RS[Your Spring Boot API\nResource Server]
RS -->|"4. Validate token\nagainst AS public key"| RS
RS -->|"5. 200 OK {data}"| Client
Your Spring Boot API is the Resource Server in this diagram. It validates every incoming Bearer token — if valid, the request proceeds; if not, 401 is returned.
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
JWT Resource Server Configuration
Minimal: Auto-Configure from Issuer URI
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://accounts.google.com # Spring fetches JWKS from /.well-known/openid-configuration
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
}
With just the issuer-uri, Spring Security:
- Downloads
https://accounts.google.com/.well-known/openid-configuration - Extracts the
jwks_uri - Downloads the public keys from
jwks_uri - Validates every incoming JWT signature against those keys
- Refreshes keys when a new
kidis encountered
JWKS URI Directly
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://auth.example.com/.well-known/jwks.json
Local Public Key (for testing or when auth server is internal)
spring:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:public-key.pem
JWT Claims and the Authentication Object
After JWT validation, Spring creates a JwtAuthenticationToken in the SecurityContext:
@GetMapping("/api/me")
public Map<String, Object> profile(Authentication authentication) {
JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
Jwt jwt = jwtAuth.getToken();
return Map.of(
"subject", jwt.getSubject(),
"issuer", jwt.getIssuer().toString(),
"expires", jwt.getExpiresAt(),
"scopes", jwt.getClaimAsStringList("scope"),
"email", jwt.getClaimAsString("email"),
"authorities", authentication.getAuthorities()
);
}
Or use @AuthenticationPrincipal Jwt:
@GetMapping("/api/orders")
public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
return orderService.findByUserId(userId);
}
Custom Claims Converter: Extracting Roles
By default, Spring Security maps JWT scope claims to SCOPE_read, SCOPE_write authorities. For custom role claims, add a JwtAuthenticationConverter:
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
// Option 1: Map scopes (default behavior, just change prefix)
converter.setAuthorityPrefix("SCOPE_"); // default
converter.setAuthoritiesClaimName("scope"); // default
// Option 2: Map custom roles claim
converter.setAuthorityPrefix("ROLE_");
converter.setAuthoritiesClaimName("roles"); // your custom claim
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
http.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
Complex Authority Mapping
For JWTs with nested claims or multiple authority sources:
@Component
public class CustomJwtGrantedAuthoritiesConverter
implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Set<GrantedAuthority> authorities = new HashSet<>();
// Map roles claim: ["ADMIN", "USER"] → [ROLE_ADMIN, ROLE_USER]
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.forEach(authorities::add);
}
// Map scopes: "read:products write:orders" → [SCOPE_read:products, SCOPE_write:orders]
String scope = jwt.getClaimAsString("scope");
if (scope != null) {
Arrays.stream(scope.split(" "))
.map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
.forEach(authorities::add);
}
// Map Keycloak realm_access.roles nested structure
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess != null) {
List<?> realmRoles = (List<?>) realmAccess.get("roles");
if (realmRoles != null) {
realmRoles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.toString().toUpperCase()))
.forEach(authorities::add);
}
}
return authorities;
}
}
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(customJwtGrantedAuthoritiesConverter);
Additional JWT Validation
Validate extra claims beyond signature and expiration:
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder
.withJwkSetUri(jwkSetUri)
.build();
// Validate the audience claim
OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
JwtClaimNames.AUD,
aud -> aud != null && aud.contains("my-api-identifier")
);
// Validate the issuer
OAuth2TokenValidator<Jwt> issuerValidator =
JwtValidators.createDefaultWithIssuer("https://auth.example.com");
// Combine validators
OAuth2TokenValidator<Jwt> combined =
new DelegatingOAuth2TokenValidator<>(issuerValidator, audienceValidator);
jwtDecoder.setJwtValidator(combined);
return jwtDecoder;
}
Opaque Token Resource Server
When the authorization server issues opaque tokens (not JWTs), the resource server must introspect each token:
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://auth.example.com/oauth2/introspect
client-id: resource-server-client-id
client-secret: ${INTROSPECTION_SECRET}
http.oauth2ResourceServer(oauth2 -> oauth2
.opaqueToken(Customizer.withDefaults())
);
Spring Security calls the introspection endpoint for every request — consider caching:
@Bean
public OpaqueTokenIntrospector introspector(OAuth2ResourceServerProperties properties) {
NimbusOpaqueTokenIntrospector delegate = new NimbusOpaqueTokenIntrospector(
properties.getOpaquetoken().getIntrospectionUri(),
properties.getOpaquetoken().getClientId(),
properties.getOpaquetoken().getClientSecret()
);
return new CachingOpaqueTokenIntrospector(delegate); // see below
}
public class CachingOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
private final Cache<String, OAuth2AuthenticatedPrincipal> cache =
Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(1)).build();
public OAuth2AuthenticatedPrincipal introspect(String token) {
return cache.get(token, key -> delegate.introspect(key));
}
}
Scope-Based Authorization
Authorization based on OAuth2 scopes:
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**")
.hasAuthority("SCOPE_read:products")
.requestMatchers(HttpMethod.POST, "/api/products/**")
.hasAuthority("SCOPE_write:products")
.requestMatchers("/api/admin/**")
.hasRole("ADMIN")
.anyRequest().authenticated()
);
Or with method security:
@GetMapping("/api/products")
@PreAuthorize("hasAuthority('SCOPE_read:products')")
public List<Product> getProducts() { ... }
@PostMapping("/api/products")
@PreAuthorize("hasAuthority('SCOPE_write:products') and hasRole('ADMIN')")
public Product createProduct(@RequestBody ProductRequest request) { ... }
Multi-Tenant Resource Server
Accept tokens from multiple authorization servers:
@Bean
public JwtDecoder jwtDecoder() {
return token -> {
// Decode without verification to read the issuer claim
Jwt unverifiedJwt = NimbusJwtDecoder.withJwkSetUri("placeholder").build()
.decode(token); // will fail signature check, but we only need the 'iss' claim
// Route to the correct decoder based on issuer
String issuer = unverifiedJwt.getIssuer().toString();
JwtDecoder decoder = resolveDecoderForIssuer(issuer);
return decoder.decode(token);
};
}
private final Map<String, JwtDecoder> decoders = new ConcurrentHashMap<>();
private JwtDecoder resolveDecoderForIssuer(String issuer) {
return decoders.computeIfAbsent(issuer, iss -> {
// Build a decoder for this issuer — downloads its JWKS
return JwtDecoders.fromIssuerLocation(iss);
});
}
Summary
- Add
spring-boot-starter-oauth2-resource-serverand configureissuer-uriorjwk-set-uri— Spring auto-downloads public keys and validates JWTs. - Use
JwtAuthenticationConverterto map JWT claims to Spring Security authorities (ROLE_*from arolesclaim,SCOPE_*fromscope). - For Keycloak/Okta with nested role claims, implement a custom
Converter<Jwt, Collection<GrantedAuthority>>. - Add custom validators (audience, issuer, custom claims) via
JwtDecoder.setJwtValidator(). - For opaque tokens, configure introspection endpoint — cache results to avoid per-request round trips.
Next: Article 15 covers Spring Authorization Server — building your own OAuth2/OIDC authorization server to issue tokens for your own ecosystem.