Resource Server with JWT

Turn a Spring Boot API into an OAuth2 resource server that verifies bearer JWTs from an external issuer, and know exactly which checks run.

Syntaxspring.security.oauth2.resourceserver.jwt.issuer-uri=https://issuer spring.security.oauth2.resourceserver.jwt.audiences=my-api http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

A resource server accepts Authorization: Bearer tokens and rejects everything else. Add spring-boot-starter-oauth2-resource-server and tell Boot who issues your tokens:

spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com/realms/shop
spring.security.oauth2.resourceserver.jwt.audiences=orders-api

From issuer-uri, Boot builds a JwtDecoder that reads the issuer's OpenID Connect metadata to find its JWK Set URI, downloads the public keys and caches them. Each token is then checked for:

  • a valid signature from a key in that set, chosen by the token's kid;
  • exp and nbf, with 60 seconds of allowed clock skew by default;
  • iss equal to the configured issuer;
  • aud containing one of audiences, but only if you set that property.

Always set audiences. Without it, any token from the same issuer works, including one minted for a different API that happens to share the identity provider.

Your own SecurityFilterChain

With only properties, Boot's default chain requires authentication on every request. As soon as you declare a SecurityFilterChain bean, Boot's default backs off and you must switch JWT handling on yourself:

http
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/actuator/health").permitAll()
        .anyRequest().authenticated())
    .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
    .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    .csrf(csrf -> csrf.disable());

Forgetting oauth2ResourceServer is the classic mistake: every request comes back 401 or 403 because nothing reads the header. The decoder bean that Boot built from your properties is still used.

Disabling CSRF is safe here because the browser does not attach bearer tokens automatically the way it attaches cookies. If the same app also uses session cookies, keep CSRF for those routes.

What failures look like

A request with no token gets 401 and a plain WWW-Authenticate: Bearer challenge with no error code. An expired, malformed or badly signed token also gets 401, and the challenge then carries error="invalid_token" and a description of the problem. A valid token without the required authority gets 403. Clients should refresh on 401 and give up on 403.

Keys and rotation

Prefer an issuer that signs with RS256 or ES256 and publishes a JWK Set. When it rotates keys, new tokens carry a new kid, the decoder fetches the updated set, and nothing in your app changes. Setting jwk-set-uri as well as issuer-uri makes Boot use that URI for keys directly, which helps when the metadata endpoint is not reachable from inside your network. Avoid a shared HMAC secret between many services: anyone who can verify a token can then also forge one.

Example

Example · java
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Configuration
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health/**").permitAll()
                .requestMatchers("/api/**").authenticated()
                .anyRequest().denyAll())
            // Uses the JwtDecoder Boot builds from spring.security.oauth2.resourceserver.jwt.*
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable());
        return http.build();
    }
}

record OrderSummary(long id, String customerId, String status) {
}

@RestController
class MyOrdersController {

    @GetMapping("/api/my/orders")
    List<OrderSummary> myOrders(@AuthenticationPrincipal Jwt jwt) {
        String customerId = jwt.getSubject();
        // Look orders up by the verified subject, never by an id the client sends.
        return List.of(new OrderSummary(1L, customerId, "PAID"));
    }
}

When to use it

  • An orders API trusts tokens from the company Keycloak realm and checks that each one was issued for orders-api.
  • A multi-tenant SaaS API rejects tokens whose tenant claim does not match the deployment.
  • A service inside a private network fetches signing keys from an internal JWK Set URL instead of the public discovery endpoint.

More examples

Configuration for an external issuer

<code>issuer-uri</code> still drives the <code>iss</code> check when <code>jwk-set-uri</code> is set. Listing <code>jws-algorithms</code> pins the accepted signing algorithms, so a token signed some other way is refused. These keys suit an external provider; if you sign tokens yourself, declare a <code>JwtDecoder</code> bean instead.

Example · yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/shop
          # Fetch keys directly instead of through the discovery document.
          jwk-set-uri: https://auth.example.com/realms/shop/protocol/openid-connect/certs
          audiences:
            - orders-api
          jws-algorithms:
            - RS256

Adding a custom claim check to the decoder

Declaring a <code>JwtDecoder</code> bean replaces the one Boot would build, so the <code>audiences</code> property no longer applies and the checks must be added here. <code>withIssuerLocation</code> contacts the issuer when the bean is built, so startup fails if it is unreachable. A failed validator produces the same 401 <code>invalid_token</code> response as an expired token.

Example · java
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtAudienceValidator;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

@Configuration
public class JwtDecoderConfig {

    @Bean
    JwtDecoder jwtDecoder(@Value("${app.security.issuer}") String issuer) {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();

        OAuth2TokenValidator<Jwt> tenant =
                new JwtClaimValidator<String>("tenant", "acme"::equals);

        // setJwtValidator replaces the defaults, so keep them: createDefaultWithIssuer
        // checks exp, nbf and iss. The extra validators run alongside.
        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
                JwtValidators.createDefaultWithIssuer(issuer),
                new JwtAudienceValidator("orders-api"),
                tenant));
        return decoder;
    }
}

Testing the endpoint with a mock JWT

<code>jwt()</code> from spring-security-test places an already-authenticated <code>JwtAuthenticationToken</code> in the security context, so no real issuer or key is involved. Test both sides: the request without a token must be refused, which catches a chain that forgot <code>oauth2ResourceServer</code> or permitted too much. The controller and the rules are imported explicitly and the rules class is a <code>@TestConfiguration</code>: a nested <code>@Configuration</code> class would be used instead of the application's configuration, leaving the controller unregistered and the authenticated request a 404.

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@WebMvcTest(MyOrdersControllerTest.MyOrdersController.class)
@Import({MyOrdersControllerTest.MyOrdersController.class, MyOrdersControllerTest.SecurityConfig.class})
class MyOrdersControllerTest {

    @RestController
    static class MyOrdersController {
        @GetMapping("/api/my/orders")
        List<String> myOrders(@AuthenticationPrincipal Jwt jwt) {
            return List.of("order-for-" + jwt.getSubject());
        }
    }

    // @TestConfiguration, not @Configuration: a nested @Configuration class would replace
    // the application's configuration, and the controller would never be registered.
    @TestConfiguration
    static class SecurityConfig {
        @Bean
        SecurityFilterChain api(HttpSecurity http) throws Exception {
            return http
                    .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                    .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
                    .build();
        }
    }

    @Autowired
    MockMvcTester mvc;

    // The jwt() post-processor bypasses decoding, but the configurer still needs a decoder bean.
    @MockitoBean
    JwtDecoder jwtDecoder;

    @Test
    void rejectsRequestsWithoutToken() {
        assertThat(mvc.get().uri("/api/my/orders")).hasStatus(401);
    }

    @Test
    void usesTheTokenSubject() {
        assertThat(mvc.get().uri("/api/my/orders").with(jwt().jwt(j -> j.subject("customer-42"))))
                .hasStatusOk()
                .bodyText().contains("order-for-customer-42");
    }
}

Discussion

  • Be the first to comment on this lesson.