Spring Boot
A Spring Boot REST API validates Nauthera access tokens with Spring Security's OAuth 2.0 resource server. It reads the issuer's discovery document once, fetches the signing keys from its JWKS and checks every token locally: no call to Nauthera per request.
The API in this guide is named https://orders.example.com/, serves /orders, and
expects the scopes orders:read and orders:write. It needs Spring Boot 3.1 or later
(Spring Security 6.1 or later).
1. Register the callers
The API itself needs no client. Each service that calls it does. A backend service with
no user gets its token with the client_credentials grant:
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
name: billing-worker
namespace: billing
spec:
clientId: billing-worker
issuer: https://auth.example.com
displayName: Billing worker
grantTypes:
- client_credentials
scopes:
- orders:read
tokenEndpointAuthMethod: client_secret_basic
secret:
generate: truekubectl apply -f billing-worker-client.yaml
kubectl -n billing get oidcclient billing-worker # PHASE Ready, reason RegisteredThe operator writes client-id, client-secret and issuer to the Secret
billing/billing-worker; mount them as the overview
shows. A web app whose users call the API registers as in the
Better Auth guide, with orders:read added to its
spec.scopes and resource=https://orders.example.com/ on its authorization request.
2. Add the dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>For Gradle: implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server").
3. Configure the issuer and audience
# src/main/resources/application.yml
nauthera:
issuer: https://auth.example.com
audience: https://orders.example.com/In Kubernetes, set them with NAUTHERA_ISSUER and NAUTHERA_AUDIENCE, which Spring's
relaxed binding maps onto these keys. For an organization other than the main one, the
issuer is https://auth.example.com/orgs/acme.
4. Build the decoder and the filter chain
Spring Boot's spring.security.oauth2.resourceserver.jwt.* properties cover the issuer
and the audience, but Nauthera's tokens need two things they do not: the ES256 algorithm
(Spring accepts only RS256 unless told otherwise) and the RFC 9068 token type at+jwt
(Spring's default type check accepts only JWT or none). Declare the decoder yourself:
package com.example.orders;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import java.util.function.Predicate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
class SecurityConfig {
private static final String ACCESS_TOKEN_TYPE = "at+jwt";
@Bean
JwtDecoder jwtDecoder(@Value("${nauthera.issuer}") String issuer,
@Value("${nauthera.audience}") String audience) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer)
.jwsAlgorithms(algs -> {
algs.add(SignatureAlgorithm.ES256);
algs.add(SignatureAlgorithm.RS256);
})
.jwtProcessorCustomizer(processor -> processor.setJWSTypeVerifier(
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType(ACCESS_TOKEN_TYPE))))
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(),
new JwtIssuerValidator(issuer),
require(jwt -> ACCESS_TOKEN_TYPE.equals(jwt.getHeaders().get("typ")),
"not an access token"),
require(jwt -> jwt.getAudience() != null && jwt.getAudience().contains(audience),
"wrong audience"),
// A token with cnf is DPoP-bound; this API does not verify DPoP proofs.
require(jwt -> !jwt.hasClaim("cnf"), "sender-constrained token")));
return decoder;
}
private static OAuth2TokenValidator<Jwt> require(Predicate<Jwt> test, String description) {
return jwt -> test.test(jwt)
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", description, null));
}
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/orders/**").hasAuthority("SCOPE_orders:read")
.requestMatchers("/orders/**").hasAuthority("SCOPE_orders:write")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
}setJwtValidator replaces Spring's default validators, so the list names every check
itself rather than relying on what a given Spring Security version adds by default:
| Check | Where |
|---|---|
| Signature against the issuer's JWKS, ES256 or RS256 | withIssuerLocation, jwsAlgorithms |
Header typ is at+jwt, so an ID token is not accepted as an access token | the first require (and the Nimbus type verifier, which would otherwise refuse it first) |
exp and nbf hold, with a minute of clock skew | JwtTimestampValidator |
iss is the issuer | JwtIssuerValidator |
aud contains this API | the second require |
| Not DPoP-bound | the third require |
| The scope the endpoint needs | hasAuthority("SCOPE_…") |
withIssuerLocation reads {issuer}/.well-known/openid-configuration, refuses a document
whose issuer differs, and takes the JWKS location from it. The keys are cached and
fetched again when a token names a key id the cache does not hold, so a key rotation
needs no restart.
Spring turns the space-separated scope claim into SCOPE_ authorities, so
hasAuthority("SCOPE_orders:read") and @PreAuthorize("hasAuthority('SCOPE_orders:read')")
(with @EnableMethodSecurity) work without further mapping.
5. Read the caller in a controller
@RestController
@RequestMapping("/orders")
class OrderController {
@GetMapping
List<Order> list(@AuthenticationPrincipal Jwt jwt) {
String subject = jwt.getSubject(); // the user, or the client for client_credentials
String client = jwt.getClaimAsString("client_id"); // the application that called
return orders.findVisibleTo(subject);
}
}A user's token also carries email, name and preferred_username when the caller was
granted the email and profile scopes.
6. Call it
From the billing-worker pod, or locally with its credentials:
TOKEN=$(curl -s https://auth.example.com/token \
-u "$NAUTHERA_CLIENT_ID:$NAUTHERA_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope=orders:read \
-d resource=https://orders.example.com/ | jq -r .access_token)
curl -s https://orders.example.com/orders -H "Authorization: Bearer $TOKEN"Without resource, the token's aud is billing-worker and the API answers 401. A
token without orders:read gets 403. The WWW-Authenticate header says which.
Local development
Against the Quick Start stack the issuer is
https://localhost:8080, and the JVM does not read the system trust store that
mkcert -install writes to. Import the mkcert root into a trust store and point the JVM
at it:
keytool -importcert -noprompt -alias mkcert -file "$(mkcert -CAROOT)/rootCA.pem" \
-keystore dev-truststore.p12 -storepass changeit
JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=$PWD/dev-truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit" \
./mvnw spring-boot:runFor tests, SecurityMockMvcRequestPostProcessors.jwt() builds a principal with the
claims and authorities you name, without a decoder.
Notes
- A revoked or logged-out access token stays valid until it expires (10 minutes by default), because revocation does not reach access tokens yet. Keep access token lifetimes short.
- To accept DPoP-bound tokens, drop the
cnfcheck and enable Spring Security's DPoP support (6.5 and later). See Tokens & Sender Constraint. - Do not use
/introspectfrom the API: only the client a token was issued to can introspect it. See Connect an Application.