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

  1. Generate a new DPoP proof for every request with a unique jti value in the client library
  2. Ensure HTTP retry logic rebuilds the proof rather than replaying the original request body verbatim
  3. Check for clock issues or deterministic jti generation (e.g. hashing static values) in the client
  4. 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

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


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)