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.
spring.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-apiFrom 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; expandnbf, with 60 seconds of allowed clock skew by default;issequal to the configured issuer;audcontaining one ofaudiences, 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
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;
public class ResourceServerConfig {
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) {
}
class MyOrdersController {
("/api/my/orders")
List<OrderSummary> myOrders( 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.
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.
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.
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