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:

  1. Downloads https://accounts.google.com/.well-known/openid-configuration
  2. Extracts the jwks_uri
  3. Downloads the public keys from jwks_uri
  4. Validates every incoming JWT signature against those keys
  5. Refreshes keys when a new kid is 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-server and configure issuer-uri or jwk-set-uri — Spring auto-downloads public keys and validates JWTs.
  • Use JwtAuthenticationConverter to map JWT claims to Spring Security authorities (ROLE_* from a roles claim, SCOPE_* from scope).
  • 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.