{"record":{"id":"052006b72995c42d","repo":"apereo/cas","slug":"dpop-proof-has-already-been-used","errorCode":null,"errorMessage":"DPoP proof has already been used: ","messagePattern":"DPoP proof has already been used: ","errorType":"exception","errorClass":"AlreadyUsedException","httpStatus":null,"severity":"error","filePath":"support/cas-server-support-oauth-core-api/src/main/java/org/apereo/cas/support/oauth/validator/DefaultOAuth20ProofOfPossessionValidator.java","lineNumber":86,"sourceCode":"        }\n    }\n\n    protected JWKThumbprintConfirmation verifyProofOfPossession(final WebContext webContext,\n                                                                final String dPopProof,\n                                                                final String clientId) throws Throwable {\n        val algorithms = casProperties.getAuthn().getOidc().getDiscovery().getDpopSigningAlgValuesSupported()\n            .stream()\n            .map(JWSAlgorithm::parse)\n            .collect(Collectors.toSet());\n        val seconds = Beans.newDuration(casProperties.getAuthn().getOidc().getCore().getSkew()).toSeconds();\n        val endpointURI = new URI(webContext.getRequestURL());\n        val verifier = new DPoPTokenRequestVerifier(algorithms, endpointURI, seconds, seconds,\n            dPoPProofUse -> {\n                val key = dPoPProofUse.getIssuer() + \":\" + DigestUtils.sha256(dPoPProofUse.getJWTID().getValue());\n                val ticketId = TransientSessionTicketFactory.normalizeTicketId(key);\n                try {\n                    ticketRegistry.getTicket(ticketId, TransientSessionTicket.class);\n                    throw new AlreadyUsedException(\"DPoP proof has already been used: \" + dPoPProofUse.getJWTID().getValue());\n                } catch (final InvalidTicketException e) {\n                    val factory = (TransientSessionTicketFactory) ticketFactory.get(TransientSessionTicket.class);\n                    val ticket = factory.create(ticketId, Map.of(OAuth20Constants.DPOP, dPopProof, OAuth20Constants.CLIENT_ID, clientId));\n                    FunctionUtils.doUnchecked(_ -> ticketRegistry.addTicket(ticket));\n                }\n            });\n        val signedProof = getSignedProofOfPosessionJwt(dPopProof);\n        val dPopIssuer = new DPoPIssuer(new ClientID(clientId));\n        return verifier.verify(dPopIssuer, signedProof, Set.of());\n    }\n\n    protected void adjustUserProfile(final WebContext webContext,\n                                     final String dPopProof,\n                                     final String clientId,\n                                     final JWKThumbprintConfirmation confirmation) throws Throwable {\n        val manager = new ProfileManager(webContext, this.sessionStore);\n        manager.getProfile().ifPresent(Unchecked.consumer(profile -> {\n            val signedProof = getSignedProofOfPosessionJwt(dPopProof);","sourceCodeStart":68,"sourceCodeEnd":104,"githubUrl":"https://github.com/apereo/cas/blob/e7288fc434b4f4505b8452e1a57e8fb3111bb863/support/cas-server-support-oauth-core-api/src/main/java/org/apereo/cas/support/oauth/validator/DefaultOAuth20ProofOfPossessionValidator.java#L68-L104","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\nproof = buildDPoP(key, htm, htu, jti: \"fixed-id\");\n// after\nproof = buildDPoP(key, htm, htu, jti: UUID.randomUUID().toString());","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"try {\n  await sendWithDPoP(proof);\n} catch (e) {\n  if (String(e.message).startsWith('DPoP proof has already been used')) {\n    const freshProof = buildDPoP(key, htm, htu, { jti: crypto.randomUUID() });\n    await sendWithDPoP(freshProof);\n  }\n}","preventionTips":["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"],"tags":["oauth2","dpop","replay","security"],"backgroundTag":"jwt-token-expired","analyzedSha":"e7288fc434b4f4505b8452e1a57e8fb3111bb863","analyzedAt":"2026-09-08T15:39:16.015Z","contentChangedAt":"2026-09-08T15:39:16.015Z","schemaVersion":2},"datasetVersion":"2026-09-15T23:17:13.987Z"}