OAuth2 Resource Server: Validate JWTs from an Auth Provider
In production, you rarely build your own auth server. You use an external provider — Keycloak, Auth0, Okta, or AWS Cognito. This article shows how to configure Spring Boot as a Resource Server that validates JWTs issued by any OIDC-compliant provider.
The OAuth2 Architecture
┌─────────────────┐
│ Auth Server │
│ (Keycloak/Auth0) │
│ │
│ Issues JWTs │
│ Publishes JWKS │
└────────┬────────┘
│
┌────────────┐ │ JWT
│ Client │──────────►│
│ (Browser/ │ │
│ Mobile) │ │
└────────────┘ ▼
┌─────────────────┐
Bearer │ Resource Server │
Token ►│ (Spring Boot) │
│ │
│ Validates JWT │
│ via JWKS URI │
└─────────────────┘
- Client authenticates with the Auth Server and receives a JWT
- Client sends the JWT as
Authorization: Bearer <token>to the Resource Server - Resource Server validates the JWT by fetching the public key from the Auth Server’s JWKS endpoint
- If valid, the Resource Server processes the request
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Configuration — One Property Is All You Need
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://your-keycloak.example.com/realms/your-realm
# Spring fetches JWKS and issuer metadata from /.well-known/openid-configuration
For Auth0:
issuer-uri: https://your-tenant.auth0.com/
For Okta:
issuer-uri: https://your-company.okta.com/oauth2/default
For AWS Cognito:
issuer-uri: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_AbCdEfGhI
jwk-set-uri: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_AbCdEfGhI/.well-known/jwks.json
Spring Boot fetches the JWKS URI and caches the public keys. Token validation is local (no network call per request).
SecurityFilterChain for a Resource Server
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter =
new JwtGrantedAuthoritiesConverter();
// By default, Spring looks for 'scope' claim with SCOPE_ prefix
// Override to read roles from Keycloak's realm_access.roles claim
authoritiesConverter.setAuthoritiesClaimName("realm_access.roles");
authoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter;
}
}
Keycloak JWT Claim Structure
Keycloak puts roles in nested claims:
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"preferred_username": "alice",
"email": "alice@example.com",
"realm_access": {
"roles": ["USER", "offline_access"]
},
"resource_access": {
"order-service": {
"roles": ["READ_ORDERS", "WRITE_ORDERS"]
}
},
"iat": 1714726400,
"exp": 1714727400,
"iss": "https://keycloak.example.com/realms/my-realm"
}
Custom converter for Keycloak:
@Component
public class KeycloakJwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {
private static final String REALM_ACCESS_CLAIM = "realm_access";
private static final String RESOURCE_ACCESS_CLAIM = "resource_access";
private static final String ROLES_CLAIM = "roles";
private static final String CLIENT_ID = "order-service"; // your client ID
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Set<GrantedAuthority> authorities = new HashSet<>();
// Realm-level roles
Map<String, Object> realmAccess = jwt.getClaimAsMap(REALM_ACCESS_CLAIM);
if (realmAccess != null) {
List<String> realmRoles = (List<String>) realmAccess.get(ROLES_CLAIM);
if (realmRoles != null) {
realmRoles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.forEach(authorities::add);
}
}
// Client-level roles (for this specific service)
Map<String, Object> resourceAccess = jwt.getClaimAsMap(RESOURCE_ACCESS_CLAIM);
if (resourceAccess != null) {
Map<String, Object> clientAccess = (Map<String, Object>) resourceAccess.get(CLIENT_ID);
if (clientAccess != null) {
List<String> clientRoles = (List<String>) clientAccess.get(ROLES_CLAIM);
if (clientRoles != null) {
clientRoles.stream()
.map(SimpleGrantedAuthority::new) // no prefix for permissions
.forEach(authorities::add);
}
}
}
return new JwtAuthenticationToken(jwt, authorities, jwt.getClaimAsString("preferred_username"));
}
}
Register it:
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwt -> jwt
.jwtAuthenticationConverter(keycloakJwtConverter)
)
)
Accessing JWT Claims in Controllers
@GetMapping("/api/me")
public ResponseEntity<UserProfile> me(
@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(new UserProfile(
jwt.getSubject(),
jwt.getClaimAsString("preferred_username"),
jwt.getClaimAsString("email"),
jwt.getClaimAsStringList("groups")
));
}
// Or get the full Authentication
@GetMapping("/api/orders")
public List<OrderResponse> myOrders(
@AuthenticationPrincipal Jwt jwt,
Authentication auth) {
String userId = jwt.getSubject();
// auth.getAuthorities() — the extracted roles/permissions
return orderService.findByUserId(UUID.fromString(userId))
.stream().map(OrderResponse::from).toList();
}
Custom JWT Validation
Add additional validation beyond signature and expiry:
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuerUri);
// Add custom validators
OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator("order-service");
OAuth2TokenValidator<Jwt> defaultValidator = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> combined = new DelegatingOAuth2TokenValidator<>(
defaultValidator, audienceValidator
);
decoder.setJwtValidator(combined);
return decoder;
}
// Validate that the token is intended for our service
public class AudienceValidator implements OAuth2TokenValidator<Jwt> {
private final String audience;
public AudienceValidator(String audience) { this.audience = audience; }
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience().contains(audience)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "JWT audience does not include " + audience, null)
);
}
}
Token Introspection (for Opaque Tokens)
If the Auth Server issues opaque tokens (not JWTs), use introspection:
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://auth-server.example.com/oauth/introspect
client-id: order-service
client-secret: ${INTROSPECTION_CLIENT_SECRET}
Spring Boot calls the introspection endpoint for each request — more overhead than JWT validation.
Running Keycloak Locally
# docker-compose.yml
services:
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8180:8080"
volumes:
- keycloak-data:/opt/keycloak/data
docker-compose up -d keycloak
# Access admin console at http://localhost:8180/admin
# Create realm, client, and users
Configure Spring Boot to use it:
# application-dev.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8180/realms/my-realm
Integration Test with JWT
@SpringBootTest
@AutoConfigureMockMvc
class ResourceServerIntegrationTest {
@Autowired MockMvc mockMvc;
@Test
void shouldReturn401WithoutToken() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldAllowAccessWithValidJwt() throws Exception {
mockMvc.perform(get("/api/orders")
.with(jwt() // Spring Security test support
.jwt(jwtBuilder -> jwtBuilder
.subject("user-123")
.claim("realm_access", Map.of("roles", List.of("USER")))
.claim("preferred_username", "alice")
)
))
.andExpect(status().isOk());
}
@Test
void adminEndpointRequiresAdminRole() throws Exception {
mockMvc.perform(get("/api/admin/users")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isForbidden());
mockMvc.perform(get("/api/admin/users")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
}
jwt() from spring-security-test creates a mock JWT without a real auth server.
What You’ve Learned
- One property (
spring.security.oauth2.resourceserver.jwt.issuer-uri) configures JWT validation - Spring fetches the JWKS URI automatically and caches public keys — no auth server call per request
JwtAuthenticationConverterextracts roles from JWT claims (customize for Keycloak’srealm_access.roles)@AuthenticationPrincipal Jwtinjects the raw JWT into controller methods- Custom
OAuth2TokenValidatoradds validation beyond signature and expiry (e.g., audience check) - Use
.with(jwt())in tests — no real auth server needed
Next: Article 28 — OAuth2 Authorization Server — build your own authorization server with Spring Security 7.