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   │
                    └─────────────────┘
  1. Client authenticates with the Auth Server and receives a JWT
  2. Client sends the JWT as Authorization: Bearer <token> to the Resource Server
  3. Resource Server validates the JWT by fetching the public key from the Auth Server’s JWKS endpoint
  4. 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
  • JwtAuthenticationConverter extracts roles from JWT claims (customize for Keycloak’s realm_access.roles)
  • @AuthenticationPrincipal Jwt injects the raw JWT into controller methods
  • Custom OAuth2TokenValidator adds 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.