# 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 *“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](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 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` 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: ```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)