Skip to content

PKCE enforcement authority is the resolved code payload, not the persisted row

AuthCodeGrant.validateCodeChallenge opened with if (!authCode.codeChallenge) return — the persisted auth-code entity decided whether PKCE applied at all. In JWT mode the library holds a second, stronger copy of the same value: code_challenge inside the signed code, which it minted and has just verified. A consumer repository that persists codeChallenge on write but omits it on read (a projection that forgets the column, a cache row, an ORM select list) therefore disabled PKCE for every flow in the deployment, silently, while /authorize and the discovery document still advertised S256 — an intercepted code redeems for full tokens with no code_verifier. We decided the resolved payload is the enforcement authority: the challenge is validatedPayload.code_challenge ?? authCode.codeChallenge; when both sources carry one they must be equal (constant-time) or the redemption fails invalid_grant; the challenge method is read from whichever source supplied the challenge, so a rewritten row cannot downgrade S256 to plain; and when neither source carries a challenge the redemption fails invalid_grant if options.requiresPKCE is set. Both guards are unconditional — there is no option to restore the old behavior.

Considered Options

  • Keep the persisted entity authoritative and document the requirement — rejected: the failure is silent and total, the documentation cannot enforce itself, and the library already holds the stronger value in JWT mode.
  • Signed-payload authority alone, without the requiresPKCE fail-closed guard — rejected: it fixes JWT mode only. Opaque codes rebuild their payload from the persisted entity, so the row remains the single source there and a dropped column still disables PKCE. The second guard is what closes opaque mode; it also backstops JWT mode if a future encoder loses the claim.
  • An opt-out option (requirePkceChallengeAtRedemption: false, or similar) — rejected on the same grounds as ADR 0006 and ADR 0007: a switch whose only function is to re-open a vulnerability is an attractive nuisance. The guard is symmetric with the existing requiresPKCE gate at /authorize, which already refuses to issue a challenge-less code, so no flow the library itself issued can hit it.
  • Verify the round-trip at issuance instead — re-read the just-persisted code and fail loud when the challenge is missing, mirroring the opaque-mode nonce guard — rejected as the only measure: it spends a repository read on every authorization request and still catches only the write path, leaving a read-side projection that drops the column undetected. The redemption-time guard covers both and costs nothing.
  • Reject outright when the signed payload has no challenge but the row does (the pre-change behavior for that branch) — rejected in favor of enforcing against the row's challenge: an attacker cannot produce a signed payload at all, so the case is consumer-side skew, and requiring a valid verifier is strictly safer than refusing a legitimate redemption.

Consequences

  • A deployment whose authCodeRepository.getByIdentifier does not round-trip codeChallenge/codeChallengeMethod now fails loudly in opaque mode (invalid_grant at redemption) instead of quietly running without PKCE. This is the intended trade: a broken deployment is better than a silently unprotected one. docs/docs/getting_started/repositories.md states the requirement.
  • With requiresPKCE at its default true, an authorization code that carries no challenge in either source is no longer redeemable. Codes minted by the library always carry one (the /authorize gate), so this reaches only codes minted by consumer code that bypasses validateAuthorizationRequest, or codes in flight across a config change that turned requiresPKCE on.
  • Deployments running requiresPKCE: false are unaffected: challenge-less codes still redeem, and codes that do carry a challenge are still verified.
  • A signed/persisted challenge mismatch now reports invalid_grant rather than invalid_request; the redemption failed either way.
  • Challenge comparison (both verifiers and the cross-check) runs in constant time. Neither value is secret, so this is hygiene rather than a fix for an exploitable leak.