skip to content

How does NimbusJwtDecoder verify a JWT's signature, and what is the difference between configuring it with jwk-set-uri versus issuer-uri?

level: middleimportance: must knowfreq 70%

answer

  1. kid -> pick JWK -> verify signature
  2. jwk-set-uri = keys only, no issuer check
  3. issuer-uri = discovery + JwtIssuerValidator
  4. .well-known/openid-configuration -> jwks_uri
  5. JWKS cached, re-fetch on unknown kid = rotation

basics

~20 s

NimbusJwtDecoder downloads the authorization server's public keys (the JWK set), picks the key matching the token header's kid, and checks the signature. jwk-set-uri points straight at the keys; issuer-uri discovers the jwks URL and also validates the iss claim.

solid answer

~50 s

NimbusJwtDecoder wraps Nimbus JOSE+JWT. With jwk-set-uri it lazily fetches the JWK Set (JSON list of public keys, each with a kid) from that URL and caches it. Per token it reads the header alg and kid, selects the matching JWK, and cryptographically verifies the signature (RS256/ES256/etc.) — a forged or tampered token fails. It also runs default OAuth2TokenValidators for exp/nbf clock checks. issuer-uri is stronger: at startup it fetches {issuer}/.well-known/openid-configuration, reads jwks_uri from it, AND registers a JwtIssuerValidator that asserts the token's iss equals the configured issuer. So issuer-uri gives discovery plus issuer validation; jwk-set-uri only locates keys. Keys are cached with periodic refresh, and an unknown kid triggers a re-fetch, so key rotation works without redeploy. You can also build a decoder from a static public key or shared secret for symmetric HMAC tokens.

code

java · 14 lines
java
// Manual decoder: restrict algorithms and add an explicit issuer check
// even when starting from a raw jwk-set-uri.
@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/realms/katajob/protocol/openid-connect/certs")
        .jwsAlgorithm(SignatureAlgorithm.RS256) // reject 'none' / unexpected algs
        .build();

    OAuth2TokenValidator<Jwt> withIssuer =
        JwtValidators.createDefaultWithIssuer("https://auth.example.com/realms/katajob");
    decoder.setJwtValidator(withIssuer); // signature + exp/nbf + iss
    return decoder;
}

go deeper

for a junior

Know that the decoder fetches public keys and checks the signature; know the two property names.

for a middle

Explain kid-based key selection, JWKS caching, and that issuer-uri adds discovery + issuer validation while jwk-set-uri does not.

for a senior

Discuss restricting jwsAlgorithms, adding an explicit issuer validator over jwk-set-uri, and startup coupling tradeoffs.

for a principal

Reason about rotation windows, offline/air-gapped decoders, algorithm-confusion defense, and startup resilience for the whole fleet.

## JWK and JWK Set A **JWK** (JSON Web Key) is a JSON representation of a cryptographic key. A **JWK Set (JWKS)** is `{ "keys": [ ... ] }` — the authorization server's *public* keys used to verify signatures. Each key carries a `kid` (key id), `kty` (key type, e.g. RSA/EC), `alg`, and the public parameters. The auth server signs tokens with its private key and publishes the matching public keys at its `jwks_uri`. ## NimbusJwtDecoder `NimbusJwtDecoder` is Spring's default `JwtDecoder`, built on the **Nimbus JOSE + JWT** library. Verification per token: 1. Parse the compact JWT into header/payload/signature. 2. Read the header `alg` (algorithm) and `kid`. 3. Resolve the public key: select the JWK whose `kid` matches from the cached JWK set. 4. **Verify the signature** using that public key and algorithm. If the payload was altered or signed by the wrong key, verification fails and `decode()` throws `JwtException`/`BadJwtException`. 5. Run the configured `OAuth2TokenValidator<Jwt>` chain (timestamp, and — with issuer-uri — issuer). Only if all pass do you get a `Jwt` object with typed claim accessors. ## jwk-set-uri ```yaml spring.security.oauth2.resourceserver.jwt.jwk-set-uri: https://auth.example.com/realms/katajob/protocol/openid-connect/certs ``` Boot builds `NimbusJwtDecoder.withJwkSetUri(uri).build()`. The decoder fetches the JWKS **lazily on first use** and caches it (default 5-minute lifespan, refreshed on demand). This variant does NOT know the issuer, so **no issuer validator is added automatically** — only signature + timestamp are checked. Use it when the auth server has no OIDC discovery endpoint or you want to avoid a startup network dependency. ## issuer-uri ```yaml spring.security.oauth2.resourceserver.jwt.issuer-uri: https://auth.example.com/realms/katajob ``` Boot calls `JwtDecoders.fromIssuerLocation(issuer)`. **At startup** it GETs `{issuer}/.well-known/openid-configuration` (or the oauth-authorization-server variant), reads `jwks_uri`, builds the decoder, AND wires `JwtValidators.createDefaultWithIssuer(issuer)` which adds a `JwtIssuerValidator`. Net effect: signature + timestamp + **iss must equal the configured issuer**. This closes an attack where a validly-signed token from a *different* realm/tenant is replayed. Downside: the app must reach the auth server during startup, and a slow/unreachable discovery endpoint can delay or fail boot. ## Key rotation JWKS caching means the decoder tolerates rotation: if a token arrives with a `kid` not in the cache, Nimbus re-fetches the JWK set (subject to a rate limit) so new keys are picked up without a redeploy. Overlapping publish windows (old + new key both in JWKS) make rotation seamless. ## Building a decoder manually You can bypass properties with a `@Bean JwtDecoder`: - `NimbusJwtDecoder.withJwkSetUri(uri).jwsAlgorithm(RS256).build()` — restrict accepted algorithms. - `NimbusJwtDecoder.withPublicKey(rsaPublicKey).build()` — a single static RSA public key, no network. - `NimbusJwtDecoder.withSecretKey(secretKey).build()` — symmetric HMAC (HS256) tokens, where the same secret both signs and verifies. ## Gotchas - With jwk-set-uri you get NO issuer check — add a `JwtIssuerValidator` yourself if you need it. - Restricting `jwsAlgorithms` prevents algorithm-confusion / `alg: none` attacks; a decoder must never accept an unsigned or unexpected algorithm. - The discovery URL is `{issuer}/.well-known/openid-configuration` — Spring appends the well-known path; giving the full well-known URL as issuer-uri is wrong. - Startup coupling: issuer-uri fails fast if the auth server is down; some teams prefer jwk-set-uri + explicit issuer validator to decouple boot. - JWKS is fetched over HTTPS; the endpoint's TLS must be trusted by the JVM truststore.

  • Does issuer-uri validate the issuer claim automatically? Does jwk-set-uri?
    issuer-uri yes — it wires a JwtIssuerValidator so the iss claim must match. jwk-set-uri no — it only locates keys for signature verification; you must add a JwtIssuerValidator yourself if you want the check.
  • How does the resource server handle the authorization server rotating its signing keys?
    The JWKS is cached but re-fetched when a token arrives with an unknown kid. If the auth server publishes the new key (usually alongside the old during an overlap window), the decoder picks it up automatically with no redeploy.

saying these in an interview costs you the question

  • Claiming jwk-set-uri validates the issuer claim (it does not)
  • Thinking the resource server needs the auth server's private/secret key to verify RS256 tokens (it needs only the public key)
  • Saying key rotation requires a redeploy
  • Passing the full .well-known URL as issuer-uri

context