Validating a JWT properly

Spring Security · lesson 27 of 31 · 7 min read

The checks that are easy to skip, and what each one lets an attacker do.

Open this lesson in the learning hub

Key points

  • Verifying the signature is necessary and not sufficient. A token can be perfectly signed and still be the wrong token for you.
  • Check the audience. Without it, a valid token issued for a different service in the same estate is accepted here - which is exactly what token exchange exists to prevent.
  • Check the issuer, and pin it. Accepting any issuer means anyone who can stand up an OIDC provider can mint tokens your service trusts.
  • Never let the token choose its own algorithm. The historic alg: none attack, and the RS256-to-HS256 confusion where the public key is used as an HMAC secret, both come from trusting the header.
  • Cache the JWKS, but honour the kid and refresh on an unknown key. Fetching per request is a denial-of-service on your own identity provider; never refreshing breaks every rotation.
  • Allow a small clock skew - thirty seconds is typical. Without it, minor clock drift between services produces intermittent, unreproducible 401s that look like anything but a clock problem.

Example

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Discovers the JWKS endpoint and pins the expected issuer.
          issuer-uri: https://auth.example.com/realms/prod
          # Pin the algorithm. Do NOT let the token header decide.
          jws-algorithms: RS256

---

// Audience is not checked by default - you must add it.
@Bean
JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
    NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(props.getJwt().getIssuerUri());

    OAuth2TokenValidator<Jwt> withIssuer =
            JwtValidators.createDefaultWithIssuer(props.getJwt().getIssuerUri());

    OAuth2TokenValidator<Jwt> withAudience = jwt ->
            jwt.getAudience().contains("orders-api")
                    ? OAuth2TokenValidatorResult.success()
                    : OAuth2TokenValidatorResult.failure(new OAuth2Error(
                            "invalid_token", "Wrong audience", null));

    // Tolerate small clock drift between services.
    OAuth2TokenValidator<Jwt> withSkew = new JwtTimestampValidator(Duration.ofSeconds(30));

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            withIssuer, withAudience, withSkew));
    return decoder;
}

/*
 * WHAT EACH MISSING CHECK COSTS:
 *
 *   no signature check   anyone forges any token                 critical
 *   no issuer check      any provider mints tokens you trust     critical
 *   no audience check    a token for service B works on A        high
 *   alg from the header  alg:none, or RS256->HS256 confusion     critical
 *   no expiry check      a leaked token is valid forever         high
 *   JWKS never refreshed key rotation breaks all auth            outage
 *   JWKS fetched per req you DoS your own identity provider      outage
 *
 * And the one that is not a vulnerability but wastes days:
 *   no clock skew        intermittent 401s nobody can reproduce
 */

A valid signature only proves the token is genuine - audience, issuer and a pinned algorithm are what prove it is genuinely for you.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Spring Security course, and every lesson in it is listed on the Spring Security contents page.