This article assumes Spring Boot 3.x (Spring Security 6.x). The Lambda DSL syntax for
.oauth2ResourceServer(oauth2 -> ...)is available from Spring Security 6.x onward and differs from the configuration approach used in Spring Boot 2.x.
You’ll find plenty of articles covering how to issue your own JWTs with Spring Boot, but information on the other side — receiving and validating JWTs issued by an external IdP — is surprisingly sparse. If you’re using Keycloak, Cognito, Auth0, or a similar identity provider as your authentication backbone, the Spring Boot side only needs to validate those tokens. That’s the resource server pattern.
This article walks through a complete implementation using spring-boot-starter-oauth2-resource-server, covering everything from JWT signature verification to scope-based authorization and custom claim extraction.
Resource Server vs. Issuing Your Own JWTs
Let’s clarify the distinction upfront.
The article Implementing JWT Authentication with Spring Boot covers a setup where Spring Boot itself generates and issues JWTs — the authorization server and resource server coexist in the same application.
This article covers a different architecture:
- Authorization server: Keycloak, Cognito, Auth0, etc. (running as a separate service from Spring Boot)
- Resource server: Spring Boot (receives tokens and validates them — nothing more)
This pattern is the standard approach for SPA + REST API and microservices architectures. No JWT-creation code appears on the Spring Boot side at all.
Adding the Dependency
Add the following to build.gradle:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
spring-boot-starter-security is bundled inside the oauth2-resource-server starter, so no separate addition is needed.
Configuring the JWK Set URI
Set the IdP’s JWK Set URI in application.yml. Spring Security fetches the public key from this endpoint and automatically verifies JWT signatures.
spring:
security:
oauth2:
resourceserver:
jwt:
# Enable only the line for your IdP — multiple entries cannot be active simultaneously
# Keycloak
jwk-set-uri: http://localhost:8080/realms/myrealm/protocol/openid-connect/certs
# Auth0: https://{your-domain}/.well-known/jwks.json
# Cognito: https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json
Production note: When only jwk-set-uri is configured, Spring Security validates the JWT signature and expiration but does not validate the iss (issuer) claim. For production environments, using issuer-uri is recommended. It auto-discovers the JWK Set URI via the OpenID Connect discovery endpoint and validates the issuer at the same time.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/myrealm
Configuring SecurityConfig
Enable .oauth2ResourceServer() in your SecurityFilterChain:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/items/**").hasAuthority("SCOPE_read")
.requestMatchers(HttpMethod.POST, "/api/items").hasAuthority("SCOPE_write")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
This alone is enough to enable JWT validation for requests carrying an Authorization: Bearer <token> header.
How JWT Validation Works Internally
The processing flow is as follows:
BearerTokenAuthenticationFilterextracts the token from theAuthorizationheaderJwtDecoderfetches the public key from the JWK Set URI and validates the signature and expiration- On successful validation, a
JwtAuthenticationTokenis stored in theSecurityContext
Opaque token introspection requires separate configuration from the JWT approach and is out of scope for this article.
Accessing Claims in a Controller
The scope claim in a JWT is automatically converted to GrantedAuthority entries with a SCOPE_ prefix. Use @AuthenticationPrincipal to access claims directly in a controller:
@GetMapping("/api/items")
public List<Item> getItems(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
String email = jwt.getClaim("email");
// ...
}
Mapping Roles with a Custom JwtAuthenticationConverter
Keycloak stores role information in a nested claim called realm_access.roles (as documented in the Keycloak Server Administration Guide under protocol mapper configuration). Out of the box, this cannot be used with hasRole(), so a custom converter is needed. Since we also want to preserve the default scope authorities (SCOPE_xxx), the converter combines both:
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
var defaultConverter = new JwtGrantedAuthoritiesConverter(); // instantiated once at Bean initialization
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<GrantedAuthority> authorities = new ArrayList<>();
// Preserve default SCOPE_xxx authorities
defaultConverter.convert(jwt).forEach(authorities::add);
// Map Keycloak's realm_access.roles
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess != null) {
// realm_access is returned as Map<String, Object>, suppressing unchecked cast warning
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
if (roles != null) {
roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.forEach(authorities::add);
}
}
return authorities;
});
return converter;
}
Pass this converter to .jwt() in your SecurityConfig:
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
Integration with @PreAuthorize
Adding @EnableMethodSecurity to SecurityConfig enables method-level authorization:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // add this
public class SecurityConfig {
// ...
}
Then simply annotate controller methods:
@GetMapping("/api/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public List<User> getUsers() { ... }
@PostMapping("/api/items")
@PreAuthorize("hasAuthority('SCOPE_write')")
public Item createItem(@RequestBody ItemRequest request) { ... }
For details, see the method-level authorization article.
Testing with curl
First, obtain an access token from Keycloak:
# requires the jq command
# obtain a full-access token using a client with the write scope
ACCESS_TOKEN=$(curl -s -X POST \
http://localhost:8080/realms/myrealm/protocol/openid-connect/token \
-d "grant_type=client_credentials" \
-d "client_id=my-client" \
-d "client_secret=my-secret" | jq -r '.access_token')
# obtain a read-only token using a client without the write scope
READ_ONLY_TOKEN=$(curl -s -X POST \
http://localhost:8080/realms/myrealm/protocol/openid-connect/token \
-d "grant_type=client_credentials" \
-d "client_id=my-readonly-client" \
-d "client_secret=my-readonly-secret" \
-d "scope=openid read" | jq -r '.access_token')
Verify three scenarios:
# valid token → 200 OK
curl -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:8090/api/items
# no token → 401 Unauthorized
curl http://localhost:8090/api/items
# insufficient scope → 403 Forbidden
curl -X POST -H "Authorization: Bearer $READ_ONLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' http://localhost:8090/api/items
Summary
With spring-boot-starter-oauth2-resource-server, validating JWTs issued by an external IdP requires nothing more than setting jwk-set-uri (or issuer-uri for production) and enabling .oauth2ResourceServer().jwt().
- Scope-based authorization is handled via the
SCOPE_prefix - Keycloak roles are mapped via a custom
JwtAuthenticationConverter - Method-level authorization works with
@EnableMethodSecurity+@PreAuthorize
If you’re calling the API from an SPA, you’ll also need CORS configuration — see the CORS configuration article. If you’re combining this with an OAuth2 authorization code flow for social login (e.g., Google) on the IdP side, the Google social login article is also worth a read.