Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix JWT validation in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Build the JwtDecoder bean with NimbusJwtDecoder.withPublicKey(key).signatureAlgorithm(RS256) (or withIssuerLocation for rotating keys) and set its validators: JwtValidators.createDefaultWithValidators(timestamps, new JwtIssuerValidator(issuer), new JwtAudienceValidator(audience)), with a JwtTimestampValidator that requires exp. The default validators check neither issuer nor audience.

Stack
  • Language Java 21
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (Spring Security 7.1.1 OAuth2 resource server, Spring Framework 7.0.9, Spring MVC)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 25 KB
On this page

Steps

  1. Find every JwtDecoder bean and any filter that reads the Authorization header itself: JWTParser.parse(), a Base64 decode of the payload, or a decoder that builds a Jwt without NimbusJwtDecoder's verification.
  2. Let Spring Security's resource server handle the header: http.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults())) with authorizeHttpRequests(...).authenticated(). BearerTokenAuthenticationFilter answers every failure with 401 and a WWW-Authenticate: Bearer challenge.
  3. Build the decoder from configuration: NimbusJwtDecoder.withPublicKey(key).signatureAlgorithm(SignatureAlgorithm.RS256) for one key, or withIssuerLocation(issuer) or withJwkSetUri(uri) when the identity provider rotates keys.
  4. Call decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(...)) with a JwtIssuerValidator, a JwtAudienceValidator and a JwtTimestampValidator whose setAllowEmptyExpiryClaim(false) makes exp mandatory; give the timestamp validator a clock skew in seconds (30 here, 60 by default).
  5. If Spring Boot builds the decoder for you instead, set spring.security.oauth2.resourceserver.jwt.issuer-uri and audiences: Boot adds a JwtIssuerValidator, an audience validator and any OAuth2TokenValidator<Jwt> beans you declare. Declare a JwtTimestampValidator bean with setAllowEmptyExpiryClaim(false) to require exp.
  6. If your access tokens follow RFC 9068, use JwtValidators.createAtJwtValidator().issuer(issuer).audience(audience).build(), which also requires typ at+jwt, exp, sub, iat, jti and client_id.
  7. Write regression tests that send real signed tokens in the Authorization header, as this card's AccountApiTest does, and include alg none, HS256 signed with the public key, an attacker's key, an edited payload, expired tokens, no exp, another API's aud and another tenant's iss.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/accounts/impl/VulnerableTokenDecoder.javaJava
@Bean
JwtDecoder jwtDecoder() {
    // "The API gateway checks tokens, we only need the user ID."
    Converter<Map<String, Object>, Map<String, Object>> claimTypes = MappedJwtClaimSetConverter.withDefaults(Map.of());
    return token -> {
        try {
            JWT parsed = JWTParser.parse(token); // parses only: nothing is verified
            Map<String, Object> claims = claimTypes.convert(parsed.getJWTClaimsSet().getClaims());
            return Jwt.withTokenValue(token)
                    .headers(headers -> headers.putAll(parsed.getHeader().toJSONObject()))
                    .claims(values -> values.putAll(claims))
                    .build();
        } catch (ParseException ex) {
            throw new BadJwtException("Malformed token", ex);
        }
    };
}

With IMPL=vulnerable, 9 of 14 tests fail (9 exploit tests).

The fix

Fixed…/accounts/impl/FixedTokenDecoder.javaJava
@Bean
JwtDecoder jwtDecoder(RSAPublicKey issuerKey,
        @Value("${fixcard.jwt.issuer}") String issuer,
        @Value("${fixcard.jwt.audience}") String audience) {
    // The key comes from configuration, never from the token. With an identity provider that
    // rotates keys, use NimbusJwtDecoder.withIssuerLocation(issuer) or withJwkSetUri(...).
    NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(issuerKey)
            .signatureAlgorithm(SignatureAlgorithm.RS256) // the issuer's algorithm only
            .build();

    JwtTimestampValidator expiry = new JwtTimestampValidator(Duration.ofSeconds(30)); // clock skew
    expiry.setAllowEmptyExpiryClaim(false); // by default a token without "exp" never expires

    // createDefaultWithValidators keeps the typ check and uses this timestamp validator.
    decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
            expiry,
            new JwtIssuerValidator(issuer), // "iss" must equal our issuer
            new JwtAudienceValidator(audience))); // "aud" must include this API
    return decoder;
}

With IMPL=fixed, 14 of 14 tests pass.

A fix that looks safe but is not

Wrong fix…/accounts/impl/WrongTokenDecoder.javaJava
@Bean
JwtDecoder jwtDecoder(RSAPublicKey issuerKey) {
    // Signature and algorithm (RS256 by default) are checked; issuer, audience and a
    // required "exp" are not.
    return NimbusJwtDecoder.withPublicKey(issuerKey).build();
}

With IMPL=wrong, 3 of 14 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Test…/example/accounts/AccountApiTest.javaJava
// Exploit: every token below claims to be Alice. The API must answer 401 and reveal nothing of hers.

private void assertRejected(String token) throws Exception {
    MvcResult result = getAccount(token);
    assertThat(result.getResponse().getStatus()).as("the token was accepted: %s", body(result)).isEqualTo(401);
    assertThat(body(result)).doesNotContain("alice@example.test");
}

@Test
void exploitAnUnsignedTokenAlgNoneIsRejected() throws Exception {
    assertRejected(TestTokens.unsecured(Map.of("alg", "none", "typ", "JWT"), claimsFor("alice")));
}

@Test
void exploitAnHs256TokenWhoseHmacSecretIsThePublicKeyIsRejected() throws Exception {
    // The public key is public. A verifier that lets the token pick HS256 and passes it the
    // RSA public key would check this HMAC with the very bytes the attacker used.
    Map<String, Object> header = Map.of("alg", "HS256", "typ", "JWT", "kid", SEED.get("keyId"));
    byte[] publicKeyAsSecret = ISSUER_KEYS.publicKeyPem().getBytes(StandardCharsets.US_ASCII);
    assertRejected(TestTokens.signHs256(header, claimsFor("alice"), publicKeyAsSecret));
}

@Test
void exploitATokenSignedWithTheAttackersOwnKeyEmbeddedInItsJwkHeaderIsRejected() throws Exception {
    Map<String, Object> header = new LinkedHashMap<>(HEADER);
    header.put("jwk", ATTACKER_KEYS.publicJwk());
    assertRejected(TestTokens.signRs256(header, claimsFor("alice"), ATTACKER_KEYS.privateKey()));
}

@Test
void exploitMallorysTokenWithItsPayloadChangedToAlicesIsRejected() throws Exception {
    assertRejected(TestTokens.withPayload(issue(claimsFor("mallory")), claimsFor("alice")));
}

@Test
void exploitATokenThatExpiredAnHourAgoIsRejected() throws Exception {
    long t = now();
    assertRejected(issue(claimsFor("alice", Map.of("iat", t - 3900, "exp", t - 3600))));
}

@Test
void exploitATokenThatExpiredTwoMinutesAgoIsRejectedBeyondThe30SecondTolerance() throws Exception {
    long t = now();
    assertRejected(issue(claimsFor("alice", Map.of("iat", t - 420, "exp", t - 120))));
}

@Test
void exploitATokenWithoutExpIsRejected() throws Exception {
    Map<String, Object> neverExpires = claimsFor("alice");
    neverExpires.remove("exp");
    assertRejected(issue(neverExpires));
}

@Test
void exploitATokenTheIdentityProviderIssuedForAnotherApiAudIsRejected() throws Exception {
    assertRejected(issue(claimsFor("alice", Map.of("aud", SEED.get("otherAudience")))));
}

@Test
void exploitATokenFromAnotherTenantThatSharesTheSigningKeyIssIsRejected() throws Exception {
    assertRejected(issue(claimsFor("alice", Map.of("iss", SEED.get("otherIssuer")))));
}

Pitfalls

  • NimbusJwtDecoder.withPublicKey(key).build(), as shown in the reference documentation, verifies the signature, but its default validators (JwtValidators.createDefault()) check only typ, exp and nbf: a token the provider issued for another API or tenant passes.
  • JwtTimestampValidator accepts a token without exp unless you call setAllowEmptyExpiryClaim(false) (Spring Security 7.1.1), and its default clock skew is 60 seconds.
  • Declaring your own JwtDecoder bean switches off Spring Boot's decoder (@ConditionalOnMissingBean), and with it the issuer-uri and audiences properties. application.properties can still say audiences=... while nothing checks the audience.
  • Spring Boot uses public-key-location only when neither issuer-uri nor jwk-set-uri is set, so a decoder Boot builds from public-key-location never gets an issuer validator from properties; declare a JwtIssuerValidator bean or build the decoder yourself.
  • spring-security-test's jwt() post-processor puts a ready-made JwtAuthenticationToken in the security context (its default Jwt even has alg none), so tests that use it never run your JwtDecoder. They test authorization, not token validation.
  • A JwtDecoder lambda that parses the token and returns a Jwt (the gateway-already-checked-it shortcut) skips the signature, algorithm and claim checks together; NimbusJwtDecoder.decode() itself rejects alg none (PlainJWT) before any validator runs.
  • Identify users by issuer and subject together (ASVS 10.3.3) if you ever accept more than one issuer; the same sub in two tenants can be two different people.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs jwt-validation/java-spring does this for you).
  2. The nine exploit tests build tokens byte by byte with java.security and javax.crypto, all claiming to be Alice, and send them through MockMvc: alg none, HS256 signed with the public key, the attacker's key in a jwk header, Mallory's token with an edited payload, expired an hour ago, expired two minutes ago, no exp, another API's aud and another tenant's iss. Each must get 401 without Alice's data. With IMPL=fixed, TRACE logging for org.springframework.security.oauth2.server.resource.web shows the expected reason for each: Unsupported algorithm of none, Another algorithm expected, Invalid signature (twice), Jwt expired at (twice), exp is required, The aud claim is not valid, The iss claim is not valid.
  3. Against your own API, replay a token the identity provider issued for a different API and one whose payload you edited, and expect 401 with WWW-Authenticate: Bearer error="invalid_token". None of the scanners checked here models Spring Security's JwtDecoder; the regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix JWT validation in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.java-jwt.security.audit.jwt-decode-without-verify.java-jwt-decode-without-verifyNo findings. The rule is written for Auth0's java-jwt (JWT.decode() without a verifier); Semgrep CE's other Java JWT rules cover java-jwt and jjwt. None of them models Nimbus or Spring Security's JwtDecoder, so neither the parse-only decoder in VulnerableTokenDecoder nor the missing validators in WrongTokenDecoder is reported.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/java.java-jwt.security.audit.jwt-decode-without-verify.java-jwt-decode-without-verify <file> on each impl file
CodeQLjava/missing-jwt-signature-checkNot expected to report anything on this card: the query and java/ql/lib/semmle/code/java/security/JWT.qll model jjwt (io.jsonwebtoken.JwtParser parse calls without a signing key), not Nimbus JWTParser.parse() or Spring Security's NimbusJwtDecoder.Checked: github/codeql main at b5b8165; not executed
Find Security Bugs (SpotBugs plugin)n/aNo JWT bug patternNot run on this card.Checked: Bug pattern list read (143 patterns, none about JWT); not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Security 7.1.1 OAuth2 resource server, Spring Framework 7.0.9, Spring MVC)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework.boot:spring-boot-starter-security-oauth2-resource-server4.1.1 (Spring Security 7.1.1)
com.nimbusds:nimbus-jose-jwt (managed by Spring Security)10.9.1
org.apache.tomcat.embed:tomcat-embed-core (overrides Boot's 11.0.24)11.0.26
tools.jackson:jackson-bom (overrides Boot's 3.1.5)3.1.7
org.springframework.boot:spring-boot-starter-webmvc-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the jwt-validation/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).

Shell
mvn -B -q test
Shell
# Expected: every test passes
IMPL=fixed mvn -B -o -q test

# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test

# Expected: a test fails
IMPL=wrong mvn -B -o -q test

Each run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs jwt-validation/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 25 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • java OpenJDK 21.0.12.1 (Homebrew)
  • maven 3.9.16
  • springBoot 4.1.1
  • springSecurity 7.1.1
  • nimbusJoseJwt 10.9.1
  • tomcat 11.0.26
  • junitJupiter 6.0.3
Matrix
fixed: 14 of 14 tests pass; vulnerable: 9 of 14 tests fail (9 exploit tests); wrong: 3 of 14 tests fail (3 exploit tests)
Test results
fixed 14/14 pass; vulnerable 9/14 fail (all 9 exploit tests); wrong 3/14 fail (3 exploit tests: no exp, another API's aud, another tenant's iss), offline after install
Dependency freshness
Spring Boot 4.1.1 and Spring Security 7.1.1 were published on 2026-08-20; Boot 4.0 renamed the starter to spring-boot-starter-security-oauth2-resource-server; the old spring-boot-starter-oauth2-resource-server still exists but is deprecated. nimbus-jose-jwt 10.9.1 (2026-05-31) is the version Spring Security 7.1.1 declares. As in the other Java cards, the pom overrides Tomcat to 11.0.26 (2026-09-09) and Jackson to 3.1.7 (2026-09-21), because OSV lists advisories for Boot's managed Tomcat 11.0.24 and Jackson 3.1.5. OSV listed no advisories for the 84 resolved artifacts after the overrides. Maven has no lockfile; the Boot parent and the two overrides fix every version.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. Spring Security: OAuth 2.0 Resource Server JWT (trusting a single asymmetric key, configuring validation)
  2. Spring Security source: JwtValidators, JwtTimestampValidator, JwtAudienceValidator (7.1.1)
  3. Spring Boot source: resource server JwtDecoderConfiguration (4.1.1)
  4. Spring Security: testing OAuth 2.0 with MockMvc (jwt() post-processor)
  5. RFC 8725: JSON Web Token Best Current Practices (2.1 weak signatures, 3.1 algorithm verification, 3.8 issuer, 3.9 audience)
  6. OWASP JSON Web Token Cheat Sheet
  7. RFC 9068: JWT Profile for OAuth 2.0 Access Tokens (section 4, validating access tokens)
  8. RFC 6750: Bearer Token Usage (WWW-Authenticate and invalid_token)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.