# Protect a Spring Boot API with access tokens

> Protect a Spring Boot REST API with access tokens from Nauthera through Spring Security's OAuth 2.0 resource server, with callers registered as OIDCClients.

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](https://www.nauthera.io/docs/v1/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:

```yaml
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
```

```bash
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](https://www.nauthera.io/docs/v1/guides#keys-instead-of-secrets)
shows. A web app whose users call the API registers as in the
[Better Auth guide](https://www.nauthera.io/docs/v1/guides/better-auth), with `orders:read` added to its
`spec.scopes` and `resource=https://orders.example.com/` on its authorization request.

## 2. Add the dependencies

```xml
<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

```yaml
# 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:

```java
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

```java
@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:

```bash
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](https://www.nauthera.io/docs/v1/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](https://www.nauthera.io/docs/v1/quick-start#1-start-the-stack), into a trust store of its own:

```bash
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:

```bash
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](https://www.nauthera.io/docs/v1/tokens#dpop-sender-constrained-tokens).
- Do not use `/introspect` from the API: only the client a token was issued to can
  introspect it. See
  [Connect an Application](https://www.nauthera.io/docs/v1/integration#9-validate-access-tokens-in-your-api).
