OAuth2 Resource Server
A resource server is an API that protects its endpoints with access tokens issued by a separate authorization server — Google, Auth0, Keycloak, or any OIDC provider. With spring-boot-starter-oauth2-resource-server, your Spring Boot app validates incoming JWTs by fetching the issuer’s public keys and checking the signature, exp, and iss — without ever holding a signing secret. This is the standard way to secure microservice APIs and the counterpart to the client/login side.
Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
This starter pulls in Spring Security plus the JOSE/JWT decoding support. No jjwt needed — Spring decodes and validates tokens itself.
Pointing at the issuer
The cleanest configuration is a single issuer-uri. Spring fetches the provider’s OpenID configuration from {issuer}/.well-known/openid-configuration, discovers the JWK Set URL, and validates the iss claim automatically.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://accounts.google.com
If the provider does not publish a discovery document, supply the JWK Set URI directly:
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://login.example.com/.well-known/jwks.json
| Property | What it does | Validates iss? |
|---|---|---|
issuer-uri | Discovers everything from /.well-known/openid-configuration | Yes, automatically |
jwk-set-uri | Points straight at the public keys | No — add an issuer validator manually |
Note: With RS256, the resource server only ever sees public keys (from the JWK Set), so it can verify signatures but never forge tokens. Spring caches and refreshes the keys, so key rotation at the IdP needs no redeploy.
Enabling it in the filter chain
Add .oauth2ResourceServer(o -> o.jwt(...)) to the SecurityFilterChain. A resource server is stateless — clients send a bearer token on every request — so disable sessions and CSRF.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
@Configuration
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())))
.build();
}
}
That is the whole integration: any request with a valid Authorization: Bearer <jwt> is authenticated; anything else gets 401, and a valid token lacking the required authority gets 403.
Default scope mapping
By default Spring takes the scope (or scp) claim and maps each value to an authority prefixed with SCOPE_. A token with "scope": "read admin" produces authorities SCOPE_read and SCOPE_admin — which is why the rule above uses hasAuthority("SCOPE_admin").
{ "sub": "alice", "scope": "read admin", "iss": "https://login.example.com", "exp": 1718280800 }
becomes the authorities [SCOPE_read, SCOPE_admin].
Custom authority mapping with JwtAuthenticationConverter
Many IdPs put roles in a non-standard claim (Keycloak uses realm_access.roles, others use roles or permissions). Customize the conversion with a JwtAuthenticationConverter plus a JwtGrantedAuthoritiesConverter.
import org.springframework.context.annotation.Bean;
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.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Bean
JwtAuthenticationConverter jwtAuthConverter() {
// Keep the default SCOPE_ authorities from the "scope" claim.
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> authorities = new HashSet<>(scopes.convert(jwt));
// Add ROLE_* from a custom "roles" claim, e.g. ["USER","ADMIN"].
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.forEach(authorities::add);
}
return authorities;
});
return converter;
}
Now hasRole("ADMIN") and hasAuthority("SCOPE_read") both work, and the same converter feeds method security annotations like @PreAuthorize("hasRole('ADMIN')").
Tip: For nested claims (Keycloak’s
realm_access.roles), read the map withjwt.getClaimAsMap("realm_access")and pull theroleslist out of it. The Keycloak page shows the exact converter.
Reading the token in a controller
Inject the validated Jwt to read claims directly:
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ApiController {
@GetMapping("/api/whoami")
public String whoami(@AuthenticationPrincipal Jwt jwt) {
return "Hello " + jwt.getSubject() + ", scopes=" + jwt.getClaimAsString("scope");
}
}
Testing with a token
curl -s http://localhost:8080/api/whoami \
-H "Authorization: Bearer $TOKEN"
Output:
Hello alice, scopes=read admin
A missing or expired token returns a structured 401 with a WWW-Authenticate header explaining why:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Jwt expired at ..."
Warning: Resource server and login client are different starters for different jobs. Use
spring-boot-starter-oauth2-resource-serverto validate tokens on an API, andspring-boot-starter-oauth2-clientto obtain them via login. A gateway-fronted API typically needs only the resource server.