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

8.6 KiB

12 — Issuer and audience: the two claims that make a token yours

← Spring Security 7 changes · next: the validator stack →

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, produced by issuer-audience-demo.sh.

One property, three network calls

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 pins that behaviour.

This is the single most common cause of “it works with curl but not from the application” 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.

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 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.

spring.security.oauth2.resourceserver.jwt.audiences: reports-api
@Bean
OAuth2TokenValidator<Jwt> audienceValidator() {
    return new JwtAudienceValidator("reports-api");
}
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 “short-lived tokens” — your real worst case is the lifetime plus a minute.

To change it you have to build the validator yourself:

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:

// accept the type explicitly
JwtTypeValidator types = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt");
types.setAllowEmpty(true);
// 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 and 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 · next: the validator stack →