Spring Security DPoP proof cache-based replay¶
Source: GitHub Security Advisories, 2026-08-26 hourly scan: GHSA-3488-4mh8-47j4 / CVE-2026-41707 (high). Affected lines: 7.1.0, 7.0.0–7.0.6, 6.5.0–6.5.11 (per the GH advisory record; confirm the exact fixed revision against the vendor release notes before claiming a range).
This is a durable, replayable operator finding because it is a clean cache-eviction -> replay primitive against a token-binding mechanism. DPoP (Demonstrating Proof of Possession, RFC 9449) exists to stop exactly the "steal the JWT and reuse it" class of attack; the reusable lesson here is that a replay-prevention cache that is bounded can itself be the weakness — an attacker who can send cheap requests can evict the entry for a victim's jti, so the victim's still-valid DPoP proof is no longer seen as "already consumed" and can be replayed. It belongs on the same page family as the 2026-04-29 Spring Security batch and feeds the OAuth token-theft methodology.
What changed¶
DPoPProofJwtDecoderFactory keeps an internal cache of JWT ID (jti) claims so that a DPoP proof JWT is not accepted more than once. That cache has a strict size limit. Because the cache is bounded and evictable, an attacker can flood the server with dummy requests to push out the cache entry for a target jti. Once the victim's jti is evicted, an intercepted valid DPoP proof (a stolen token that the victim already used once) can be replayed successfully.
The reusable pattern, split into two trust transitions:
- Cache-bounded uniqueness. Uniqueness of a
jtiis enforced only for as long as thejtistays in memory. A bounded LRU/size-limited cache makes uniqueness time- and load-dependent, not absolute. - Interceptor's ability to drive eviction. The replay attacker controls the volume of requests that compete for cache slots, so they control when a target
jtiis dropped.
Operator triage¶
- Fingerprint DPoP usage. Confirm the target actually uses Spring Security's
DPoPProofJwtDecoderFactory(DPoP token-binding on a resource server). Look forDPoPrequest headers on protected endpoints, Spring Security OAuth2 resource-server dependencies, and the Spring Security version. - Determine the cache model. You need to know the cache type (bounded LRU? size-limited? TTL-bucketed?) and its size, because the eviction test depends on being able to push a target
jtiout. The advisory's "strict size limit" is the precondition. - Prioritize by value of the bound endpoint. The replay is only interesting if the replayed proof authorizes access to a valuable resource. Rank by what the DPoP-protected endpoint returns.
- Separate interception from eviction. The replay needs (a) a captured valid DPoP proof and (b) successful eviction of its
jti. Report them as two independent, testable steps.
Replayable validation boundaries¶
Validate on an authorized lab Spring Security resource server that you control, with a synthetic DPoP-protected endpoint and synthetic keys. Do not target a production resource server, do not capture or replay real user DPoP proofs, and do not retain real tokens.
Lab setup¶
- A lab Spring Security resource server (
6.5.x/7.0.xin-range) exposing one DPoP-protected endpoint that returns a canary value when the DPoP proof is valid. - Synthetic DPoP keys: mint a fresh key pair and issue DPoP proof JWTs yourself, so every
jtiandjwkin the test is yours. - A second actor (attacker) that can send a high volume of cheap requests to the same server to drive cache pressure.
Interception step (proves possession of a replayable proof)¶
- As the synthetic "victim," obtain a valid DPoP proof and call the protected endpoint. Confirm it returns the canary (proof is accepted,
jtinow recorded). - Record the exact
jtiand the full DPoP header/proof. This is the "intercepted" artifact you will replay.
Eviction step (proves the bound can be driven)¶
- As the attacker, send a large burst of synthetic requests (dummy DPoP proofs / requests) against the server to fill the bounded cache.
- Determine whether the victim's
jtihas been evicted — the observable is the next step.
Replay step (the finding)¶
- Resend the victim's captured DPoP proof (same
jti, same token) after eviction. - The vulnerable result is the protected endpoint accepting the replayed proof and returning the canary again. The secure result is rejection (proof already consumed /
jtistill cached / proof expired). - Record: the
jti, the burst size, the time between first use and replay, and accept/reject on both the affected and the patched control. This proves the eviction->replay primitive.
Reporting heuristics¶
- Frame as bounded
jtireplay cache -> attacker-driven eviction -> replay of an intercepted DPoP proof. Name the factory (DPoPProofJwtDecoderFactory), the cache property (strict size limit), and the version range. - Report eviction and replay as two distinct, reproducible steps, each with its own observable (cache pressure vs. re-acceptance of a known
jti). - State the version before/after and the exact fixed revision from the vendor notes (confirm it; the GH record lists the affected lines).
- Distinguish this replay-from-eviction from a plain "proof not bound to the sender" bug: here the proof was valid and bound; the cache evictability is what re-exposes it.
Safety¶
- Authorized lab only. Run a resource server you own; never run eviction bursts against a production resource server.
- Synthetic keys and proofs only. Mint your own DPoP keys and
jtis; never capture, replay, or retain real user DPoP proofs or real tokens. - Bounded load. Keep the eviction burst low-volume and targeted at the lab instance; this is a cache-pressure test, not a production DoS.
- No real authorization abuse. The proof endpoint returns a canary; do not wire it to a real, valuable operation in the lab.