Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

Protect a Spring Boot API with access tokens

Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated

This guide protects a Spring Boot REST API with access tokens from Nauthera and registers the services that call it. You need a Nauthera issuer with its operator running in the cluster, and Spring Boot 3.1 or later (Spring Security 6.1 or later). At the end, the API checks every token's signature, issuer, audience and scopes locally, with no call to Nauthera per request. The main limit: callers authenticate with a generated client secret, and the API accepts bearer tokens only, not DPoP-bound ones.

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      # a namespace the organization admits
spec:
  clientId: billing-worker
  displayName: Billing worker
  grantTypes:
    - client_credentials
  scopes:
    - orders:read
  tokenEndpointAuthMethod: client_secret_basic
  secret:
    generate: true
kubectl apply -f billing-worker-client.yaml
kubectl -n billing get oidcclient billing-worker   # PHASE Ready, reason Registered

The 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:

CheckWhere
Signature against the issuer's JWKS, ES256 or RS256withIssuerLocation, jwsAlgorithms
Header typ is at+jwt, so an ID token is not accepted as an access tokenthe first require (and the Nimbus type verifier, which would otherwise refuse it first)
exp and nbf hold, with a minute of clock skewJwtTimestampValidator
iss is the issuerJwtIssuerValidator
aud contains this APIthe second require
Not DPoP-boundthe third require
The scope the endpoint needshasAuthority("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:8443, and the JVM does not read the system trust store, so import the chart's local CA, nauthera-local-ca.crt from step 1, into a trust store of its own:

keytool -importcert -noprompt -alias nauthera-local -file nauthera-local-ca.crt \
  -keystore dev-truststore.p12 -storepass changeit

Then point the JVM at the trust store:

JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=$PWD/dev-truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit" \
  ./mvnw spring-boot:run

For 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. Keep access token lifetimes short.
  • To accept DPoP-bound tokens, drop the cnf check and enable Spring Security's DPoP support (6.5 and later). See Tokens & Sender Constraint.
  • Do not use /introspect from the API: only the client a token was issued to can introspect it. See Connect an Application.