A useful PKCE test starts with two independent authorization attempts and proves that their credentials cannot be exchanged. Test the verifier, client and redirect binding, expiry and repeat redemption separately. Run this against a local authorization-server fixture or an approved disposable environment; replaying a customer action is unnecessary. The deliverable is a conformance matrix showing the request stage, expected refusal and whether a credential was issued.
Keep the authorization attempt intact
Create a distinct verifier and state value for each synthetic client. Retain the challenge method, redirect URI, client identity and intended resource alongside the authorization attempt. Treat state validation and PKCE as different checks: a matching state does not make a wrong verifier correct. Use the exact redirect matching rules required by the client type and your provider, including documented native-client exceptions rather than an improvised hostname comparison.
RFC 9700 explains current OAuth security practice, including PKCE and redirect protections. Its requirements need to be mapped to both the authorization endpoint and token exchange. Testing only the initial browser redirect leaves the exchange behavior unknown. An authorization endpoint accepting a challenge is evidence of that one step, not evidence that every later boundary passed.
RFC 9700: authorization-code and PKCE security guidance
Build two disposable attempts
For a synthetic test, name the attempts A and B and keep their generated secrets in process memory. Derive each S256 challenge locally. The following snippet is illustrative preparation code, not a complete OAuth server. It deliberately prints neither verifier nor challenge; the test report should use attempt labels and outcome categories instead.
import base64, hashlib, secrets
verifier_a = secrets.token_urlsafe(48)
verifier_b = secrets.token_urlsafe(48)
def challenge(verifier):
digest = hashlib.sha256(verifier.encode('ascii')).digest()
return base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
assert challenge(verifier_a) != challenge(verifier_b)Issue a fresh authorization code for every independent negative test. Otherwise an expired or already consumed code can make a wrong-verifier test pass for the wrong reason. Record the order of validation and the resulting state according to the provider's contract. A refusal should produce no usable access credential, but a failed exchange may deliberately invalidate the code; do not assume the same code remains available for a later successful exchange.
| Case | Isolated input | Expected boundary |
|---|---|---|
| Wrong verifier | A code, B verifier | No usable credential |
| Wrong redirect | Fresh code, changed redirect | Reject binding mismatch |
| Expired code | Fresh code, expired clock | No usable credential |
| Repeat redemption | Previously accepted code | Reject reuse |

Make single use an observable property
For the repeat-redemption case, complete one valid exchange and then submit the identical code again from the same disposable client. Also race two valid redemptions if the server promises atomic single use. Inspect the durable claim outcome rather than accepting two HTTP responses at face value. A token response lost on the network creates a different recovery question from a code never redeemed; avoid having the test client repeat unrelated business writes while investigating sign-in.
Record expiry using the fixture's controllable clock where possible. Test just before and after the boundary with a defined clock-skew allowance. A broad sleep makes the suite slow and can obscure whether the tested rule was code expiry, request expiry or a provider session timeout. Keep the date, server revision and relevant client configuration in the result artifact so a later dependency upgrade can reproduce the same cases.
What a publishable report should contain
Report attempt label, client type, protocol revision, test case, response class and whether any credential was issued. Redact authorization codes, refresh credentials, session cookies and callback query strings from diagnostics. Keep the complete secret-bearing trace only inside the approved test harness if it is actually needed, with an explicit retention rule. A public article needs the mechanism and expected result, not a reusable captured credential.
This guide supplies an illustrative test design; it does not report a live provider replay test. Before claiming compatibility, run the matrix against the actual token endpoint and supported MCP hosts. A passing local PKCE function proves only challenge derivation. Sign-in cancellation, wrong resource audience and a disconnected host still need their own acceptance cases.
MCP server authentication: trace the identity all the way to the record
Evaluate the tool action, not just the answer.
Bring one tool workflow, supported hosts and a sanitized failure trace for a scoped AI integration review.
AptixLabs ai agent development
How do you test PKCE in an MCP authorization flow?
Use independent disposable sign-ins to test verifier and redirect binding, expiry and repeat redemption, then inspect whether an access credential was issued.
Can one code be reused across all negative tests?
Use a fresh code per independent case; a failed exchange may consume or invalidate it under the provider contract.
Does PKCE replace redirect validation?
No. Bind and validate the redirect contract separately from the verifier and authorization-code checks.
