CVE-2026-41707
This Vulnerability has been fixed in the Never-Ending Support (NES) version offered by HeroDevs.
Overview
Spring Security is the authentication and access control framework for Spring-based applications, providing filter-chain based request authorization, protection against common exploits, and a full OAuth 2.0 implementation. Its resource server support includes Demonstrating Proof of Possession (DPoP), a sender-constraining mechanism in which a client binds an access token to a key pair and proves possession of the private key on every call by presenting a signed, single-use DPoP proof JWT.
An Authorization Bypass vulnerability (CVE-2026-41707) has been identified in the DPoP proof decoder, which allows attackers who can intercept a victim's valid DPoP proof to flood the resource server with requests until the proof's recorded identifier is evicted from the single-use cache, then replay the intercepted proof to impersonate the victim.
Per OWASP: Access control enforces policy such that users cannot act outside of their intended permissions. Failures typically lead to unauthorized information disclosure, modification, or destruction of all data or performing a business function outside the user's limits.
This issue affects the OAuth 2.0 DPoP proof validation of Spring Security.
Details
Module Info
- Product: Spring Security
- Affected packages:
org.springframework.security:spring-security-oauth2-jose - Affected versions: >= 6.5.0 <= 6.5.11, >= 7.0.0 <= 7.0.6, 7.1.0
- GitHub repository: https://github.com/spring-projects/spring-security
- Published packages: https://central.sonatype.com/artifact/org.springframework.security/spring-security-oauth2-jose
- Package manager: Maven
- Fixed in:
- NES for Spring Security: 6.5.13
- OSS Spring Security: 7.0.7, 7.1.1
Vulnerability Info
This High-severity vulnerability is found in the spring-security-oauth2-jose package in the OAuth 2.0 DPoP proof validation of Spring Security.
DPoP proofs are meant to be single-use. DPoPProofJwtDecoderFactory enforced that property with an inner JtiClaimValidator, which hashed each proof's jti claim and rejected the proof if the hash was already present in a process-wide cache:
private static final class JtiClaimValidator implements OAuth2TokenValidator<Jwt> {
private static final Map<String, Long> JTI_CACHE = Collections.synchronizedMap(new JtiCache());
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String jti = jwt.getId();
if (!StringUtils.hasText(jti)) {
OAuth2Error error = createOAuth2Error("jti claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
// Enforce single-use to protect against DPoP proof replay
String jtiHash;
try {
jtiHash = computeSHA256(jti);
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
Instant expiry = Instant.now().plus(1, ChronoUnit.HOURS);
if ((JTI_CACHE.putIfAbsent(jtiHash, expiry.toEpochMilli())) != null) {
// Already used
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
}
// ...
}
The cache backing that check was a LinkedHashMap with a hard-coded capacity, and its eviction policy discarded the oldest entry as soon as the map grew past that capacity:
@SuppressWarnings("serial")
private static final class JtiCache extends LinkedHashMap<String, Long> {
private static final int MAX_SIZE = 1000;
@Override
protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
if (size() > MAX_SIZE) {
return true;
}
Instant expiry = Instant.ofEpochMilli(eldest.getValue());
return Instant.now().isAfter(expiry);
}
}
The jti values that reach this cache come straight from attacker-supplied DPoP proof JWTs, and the cache is neither bounded by time alone nor shared across instances. Because removeEldestEntry returns true purely on size, the record of a proof that has already been spent survives only until 1000 further proofs have been validated on the same instance. An attacker who captures a victim's valid DPoP proof in transit can therefore mint their own DPoP proofs, send enough of them to push the victim's jti hash out of the map in insertion order, and then submit the captured proof again. The second submission finds no matching entry, putIfAbsent succeeds, validation passes, and the attacker acts with the victim's sender-constrained access token. The one-hour expiry recorded alongside each entry does not help, since size-based eviction fires long before the timestamp is ever consulted on a busy endpoint.
The remediation removes the fixed-size map entirely, replacing it with a dedicated, configurable replay validator that applications can size and back with their own store, and wiring it through the DPoP decoder factory and the resource server configuration so a deployment is no longer limited to a 1000-entry in-process window.
Mitigation
Only recent versions of Spring Security receive community support. Older lines are End-of-Life and will not receive public updates to address this issue.
Users of the affected components should apply one of the following mitigations:
- Upgrade to a currently supported version of Spring Security.
- Leverage a commercial support partner like HeroDevs for post-EOL security support.
Credits
- Yu Bao from PayPal Cybersecurity Team (finder)