apereo/cas · error · AlreadyUsedException
DPoP proof has already been used:
Error message
DPoP proof has already been used:
What it means
Thrown (as AlreadyUsedException) during DPoP proof-of-possession verification when a proof JWT with the same jti (JWT ID) from the same issuer has already been processed. CAS records each accepted proof as a TransientSessionTicket keyed by issuer + jti hash in the ticket registry; a second submission of the same proof is rejected as a replay.
Solutions
- Generate a new DPoP proof for every request with a unique jti value in the client library
- Ensure HTTP retry logic rebuilds the proof rather than replaying the original request body verbatim
- Check for clock issues or deterministic jti generation (e.g. hashing static values) in the client
- If the registry keeps tickets too long relative to proof lifetime, review TransientSessionTicket expiration settings
Example fix
// before proof = buildDPoP(key, htm, htu, jti: "fixed-id"); // after proof = buildDPoP(key, htm, htu, jti: UUID.randomUUID().toString());
Defensive patterns
Strategy: retry
Try / catch
try {
await sendWithDPoP(proof);
} catch (e) {
if (String(e.message).startsWith('DPoP proof has already been used')) {
const freshProof = buildDPoP(key, htm, htu, { jti: crypto.randomUUID() });
await sendWithDPoP(freshProof);
}
} Prevention
- Build a fresh proof with a unique jti for every HTTP request
- Never cache/replay proofs across retries
- Use random UUIDs or counters for jti, not deterministic values
When it happens
Trigger: Client reuses an identical DPoP proof JWT (same jti) on a second token/authorized request. The verifier looks up the normalized ticket id derived from issuer:jti-hash, finds it already stored, and throws before processing.
Common situations: Client library caching and resending the same DPoP proof instead of generating a fresh jti per request; retried HTTP requests replaying the exact same proof body; clock skew leading clients to generate duplicate jti values; test suites replaying captured requests.
Related errors
- Invalid cookie . Required user-agent does not match
- cannot reuse OTP
- Failed to acquire access token
- Invalid client credentials provided for registered service:
- Code verification method is unrecognized:
AI-assisted analysis of apereo/cas@e7288fc434 (2026-09-08).
Data as JSON: /api/errors/052006b72995c42d.
Report an issue: GitHub.
Appendix: source
Thrown at support/cas-server-support-oauth-core-api/src/main/java/org/apereo/cas/support/oauth/validator/DefaultOAuth20ProofOfPossessionValidator.java:86
}
}
protected JWKThumbprintConfirmation verifyProofOfPossession(final WebContext webContext,
final String dPopProof,
final String clientId) throws Throwable {
val algorithms = casProperties.getAuthn().getOidc().getDiscovery().getDpopSigningAlgValuesSupported()
.stream()
.map(JWSAlgorithm::parse)
.collect(Collectors.toSet());
val seconds = Beans.newDuration(casProperties.getAuthn().getOidc().getCore().getSkew()).toSeconds();
val endpointURI = new URI(webContext.getRequestURL());
val verifier = new DPoPTokenRequestVerifier(algorithms, endpointURI, seconds, seconds,
dPoPProofUse -> {
val key = dPoPProofUse.getIssuer() + ":" + DigestUtils.sha256(dPoPProofUse.getJWTID().getValue());
val ticketId = TransientSessionTicketFactory.normalizeTicketId(key);
try {
ticketRegistry.getTicket(ticketId, TransientSessionTicket.class);
throw new AlreadyUsedException("DPoP proof has already been used: " + dPoPProofUse.getJWTID().getValue());
} catch (final InvalidTicketException e) {
val factory = (TransientSessionTicketFactory) ticketFactory.get(TransientSessionTicket.class);
val ticket = factory.create(ticketId, Map.of(OAuth20Constants.DPOP, dPopProof, OAuth20Constants.CLIENT_ID, clientId));
FunctionUtils.doUnchecked(_ -> ticketRegistry.addTicket(ticket));
}
});
val signedProof = getSignedProofOfPosessionJwt(dPopProof);
val dPopIssuer = new DPoPIssuer(new ClientID(clientId));
return verifier.verify(dPopIssuer, signedProof, Set.of());
}
protected void adjustUserProfile(final WebContext webContext,
final String dPopProof,
final String clientId,
final JWKThumbprintConfirmation confirmation) throws Throwable {
val manager = new ProfileManager(webContext, this.sessionStore);
manager.getProfile().ifPresent(Unchecked.consumer(profile -> {
val signedProof = getSignedProofOfPosessionJwt(dPopProof);View on GitHub (pinned to e7288fc434)