1
0
Files
jwt-auth-demo/docs/12-issuer-and-audience.md
Ankur Mhatre 4dc45d5e00 Add OAuth2 resource server project: JWT validation, JWKS and key rotation
Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:

  jwt-authentication/       the hand-written filter application (unchanged, moved)
  oauth2-resource-server/   a resource server, a Keycloak compose, and a stub
                            issuer whose JWK Set can be mutated on command

The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.

Findings captured under docs/output/, all from real runs:

  * The default validator stack does not check aud. A token minted for another
    service in the same realm is accepted.
  * Spring Security builds its JWKSource with refreshAheadCache(false) and
    rateLimited(false), overriding two of Nimbus's protective defaults, and
    enables Nimbus caching only when NO Spring cache was supplied - so
    supplying one removes the five-minute expiry.
  * A key retired from the JWK Set stops being accepted at t+300s with the
    default cache, and never with a Spring cache that has no TTL.
  * 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
    through permitAll() endpoints included.
  * A hyphenated client id in an authorities-claim-expression parses as
    subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
  * A clientScopes key in a Keycloak realm import replaces the built-in scopes
    rather than adding to them.

New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
2026-08-23 11:00:56 +00:00

211 lines
8.6 KiB
Markdown

# 12 — Issuer and audience: the two claims that make a token yours
[← Spring Security 7 changes](11-spring-security-7-changes.md) · [next: the validator stack →](13-validator-stack.md)
A valid signature proves the token was minted by something holding the signing key. It
proves nothing about *who it was minted for*. Those are two different questions, and a
resource server that only answers the first one is a resource server that will accept a
token issued to a different service in the same realm.
Everything here was captured from [`rs-issuer-audience.txt`](output/rs-issuer-audience.txt),
produced by [`issuer-audience-demo.sh`](../oauth2-resource-server/scripts/issuer-audience-demo.sh).
## One property, three network calls
```yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/demo
```
That line makes Spring Boot build a `SupplierJwtDecoder`. The word that matters is
*supplier*: nothing happens at startup. On the **first token decoded**, three things occur
in order:
1. `GET {issuer-uri}/.well-known/openid-configuration` — the discovery document
2. The `issuer` value inside it is compared against your configured `issuer-uri`, and a
mismatch fails the whole decoder, not just one token
3. `GET {jwks_uri}` — the JWK Set, both to learn which algorithms the issuer signs with
and to get the keys themselves
The laziness is a feature. Your resource server starts even when the authorization server
is down; it fails on the first request instead of failing to boot. The cost is that the
first request after startup pays for two extra HTTP round trips, and any misconfiguration
in this chain shows up as a 500-flavoured `JwtDecoderInitializationException` on a request
rather than as a startup failure you would notice in a deployment pipeline.
Set `jwk-set-uri` **as well as** `issuer-uri` to skip step 1 and 2. You keep issuer
validation and lose discovery.
## `iss` is compared with `String.equals`
Not normalised. Not parsed as a URI. Compared.
```
iss = "http://localhost:9000/other"
HTTP 401
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt: The iss claim is not valid"
```
The token in that capture was signed by the correct key, by the same issuer, seconds
earlier. Only the string differed. A trailing slash is enough:
[`JwtValidationContractTests.issuerComparisonIsExactStringEquality`](../oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java)
pins that behaviour.
This is the single most common cause of *&ldquo;it works with curl but not from the
application&rdquo;* against Keycloak, because Keycloak derives `iss` from the request host
unless you pin `KC_HOSTNAME`. A token fetched through `localhost:8080` and a token fetched
through `keycloak:8080` inside a Docker network carry different issuers, and exactly one of
them matches your configuration. See [17 — Keycloak setup](17-keycloak-setup.md).
## `aud` is not checked at all by default
This is the part worth reading twice.
`JwtValidators.createDefaultWithIssuer(issuer)` — what Boot's auto-configuration uses when
you set only `issuer-uri` — builds a stack of `JwtTypeValidator`, `JwtTimestampValidator`,
`X509CertificateThumbprintValidator` and `JwtIssuerValidator`. There is no audience
validator in it.
So this happens:
```
iss = "http://localhost:9000" <- correct
aud = "billing-api" <- a different service entirely
HTTP 200
```
with the default configuration, and 401 once an audience check is added. Both transcripts
are in the repository: [`rs-issuer-audience.txt`](output/rs-issuer-audience.txt) runs with
the `audience` profile and refuses it; the assertion that the *default* stack accepts it is
in `defaultStackDoesNotCheckAudience`.
Any of these three fixes it. They are ordered by how little you have to write.
```yaml
spring.security.oauth2.resourceserver.jwt.audiences: reports-api
```
```java
@Bean
OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtAudienceValidator("reports-api");
}
```
```java
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
new JwtIssuerValidator(issuer), new JwtAudienceValidator("reports-api")));
```
The second one deserves attention: Boot's `JwtDecoderConfiguration` collects **every**
`OAuth2TokenValidator<Jwt>` bean in the context and appends it to the stack. Adding a
validator does not mean replacing the decoder, and replacing the decoder is how people
accidentally lose the issuer validator they thought they still had.
Two details of `JwtAudienceValidator` that are easy to guess wrong, both pinned by tests:
- a token with `aud: ["billing-api", "reports-api"]` **passes** — it matches any entry,
not all of them
- a token with no `aud` claim at all is **refused**, not ignored
## The clock skew is 60 seconds and you will meet it
`JwtTimestampValidator` allows 60 seconds of skew by default in both directions. A token
that expired 30 seconds ago is accepted; one that expired 90 seconds ago is not:
```
4. Expired 90 seconds ago -> HTTP 401 "Jwt expired at ..."
5. Expired 30 seconds ago -> HTTP 200
```
That is usually what you want across machines whose clocks disagree. It is not what you
want if you are writing a test that asserts a token stops working the instant it expires,
and it is not what you want if your revocation story is *&ldquo;short-lived tokens&rdquo;*
your real worst case is the lifetime plus a minute.
To change it you have to build the validator yourself:
```java
new JwtTimestampValidator(Duration.ofSeconds(5))
```
## `typ=at+jwt` is refused by the default stack
RFC 9068 defines a media type for JWT access tokens and says an access token SHOULD carry
`typ: at+jwt` in its JOSE header. The default validator stack contains
`JwtTypeValidator.jwt()`, which accepts an **absent** `typ` or `typ=JWT`, and nothing else.
A conforming RFC 9068 access token therefore gets:
```
HTTP 401 error_description="... the given typ value needs to be one of [JWT]"
```
Keycloak is not affected, because its JOSE header says `typ: JWT` and it puts `Bearer` in a
*claim* of the same name, which nothing validates. An issuer that follows RFC 9068 more
closely will trip this. Two ways out:
```java
// accept the type explicitly
JwtTypeValidator types = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt");
types.setAllowEmpty(true);
```
```java
// or validate the whole token as an RFC 9068 access token
decoder.setJwtValidator(JwtValidators.createAtJwtValidator()
.issuer(issuer).audience("reports-api").clientId("demo-client").build());
```
The second is stricter than it looks: `createAtJwtValidator()` also **requires** `exp`,
`sub`, `iat`, `jti` and `client_id` to be present. Keycloak does not emit `client_id` in an
access token, so this builder refuses Keycloak tokens until you tell it otherwise.
The two transcripts differing in exactly this one validator are
[`rs-issuer-audience.txt`](output/rs-issuer-audience.txt) and
[`rs-issuer-audience-attyp.txt`](output/rs-issuer-audience-attyp.txt).
## Where failures explain themselves
Nowhere in the response body. A resource server returns an empty body on 401 and puts the
reason in the `WWW-Authenticate` header, per RFC 6750:
```
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid",
error_uri="https://tools.ietf.org/html/rfc6750#section-3.1",
resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
```
If you are debugging with a tool that hides response headers, every claim-validation
failure looks identical. That is worth knowing before you spend an afternoon on it.
## The endpoint you did not configure
That last `resource_metadata` parameter points at something new. Spring Security 7
publishes RFC 9728 protected resource metadata automatically and advertises it in the
challenge. It answers **without a token**, on a resource server whose chain says
`anyRequest().authenticated()`:
```
$ GET /.well-known/oauth-protected-resource
HTTP 200
{"resource":"http://localhost:8081","bearer_methods_supported":["header"],
"tls_client_certificate_bound_access_tokens":true}
```
It is standards-compliant and mostly harmless, but it is a new unauthenticated endpoint
that appears on upgrade, it confirms to an unauthenticated caller which authorization
server you trust, and it will show up in your next penetration test. Know that it is
there and that it is yours.
---
[← Spring Security 7 changes](11-spring-security-7-changes.md) · [next: the validator stack →](13-validator-stack.md)