Security
OAuth PKCE: Test S256 Without Missing the Other Checks
Implement and test OAuth PKCE with S256, per-flow verifier handling, strict callback binding, and negative tests that keep login security controls separate.
In this article
OAuth PKCE binds an authorization code exchange to a secret generated for that authorization attempt. It helps prevent a party that obtains the code from exchanging it without the matching verifier. It does not replace every other control in an OAuth login flow.
The core mechanism is specified in RFC 7636, while the OAuth security best current practice in RFC 9700 provides broader guidance. Use a maintained OAuth library where possible, then test the integration rather than assuming the presence of a code_challenge field proves correct implementation.
Keep each authorization attempt separate
Generate a fresh verifier with an appropriate cryptographically secure random source for every flow. Do not reuse a hard-coded test value in production or derive it from a username, timestamp, or predictable counter. Treat the verifier as short-lived secret material.
For the S256 method, the challenge is the base64url representation of the verifier's SHA-256 digest without padding. This is not the same as ordinary Base64 of the verifier text. Encoding mistakes often produce a flow that reaches the callback but fails during token exchange.
Do not hand-roll these transformations just to avoid using a library. If you maintain the implementation, test against the standard's examples and verify the exact byte encoding. Preserve the challenge method along with the transaction state so the exchange follows the same authorization attempt.
Store the verifier in the right boundary
The client needs the verifier when it exchanges the code. Choose storage appropriate to the application's architecture and threat model. A server-side application can keep per-flow state on the server; a browser or native application has different constraints. Avoid transferring the verifier through analytics, logs, or a query string.
Multiple login tabs are a useful test. If the application stores only one global verifier, starting a second login can overwrite the first flow's state. The first callback then fails or becomes confused with the second attempt. Bind the verifier to the specific transaction and clean it up after completion or expiry.
Set a bounded lifetime. A cancelled login should not leave usable state indefinitely. Also handle retries deliberately: a failed exchange should not automatically create a new verifier for an existing code, because that verifier will not match the original challenge.
Preserve the rest of the callback checks
Validate the returned transaction state according to the flow you use. Bind the response to the expected authorization server, client, and redirect URI through the library's supported checks. When OpenID Connect is involved, apply the relevant identity-token validation as well.
PKCE proves possession of a verifier for the code exchange. It is not a general permission check for the resulting account, a substitute for redirect URI controls, or a reason to skip issuer validation. Keep each protection's purpose explicit in the integration review.
After token exchange, authorize the requested application action separately. Successfully logging in does not grant administrative access. Test the application with a normal user as well as an administrator so the login flow does not become an accidental privilege shortcut.
Build negative tests
Run the following in a controlled test environment using approved test accounts:
- Exchange a code with the correct verifier and expect the intended success.
- Exchange with a different verifier and expect rejection.
- Omit the verifier when the flow requires PKCE and expect rejection.
- Change the callback's transaction binding and expect the client to reject it.
- Attempt to use the same completed authorization response again and confirm the flow's replay protections.
Record the client and server outcomes separately. A provider rejecting a bad verifier is useful, but the application must also avoid creating a session from an incomplete or mismatched response. Do not suppress every error and redirect to a success page.
Test concurrent flows and cancelled attempts too. These cases reveal state-handling bugs that a single happy-path test will miss. Keep test credentials out of the repository, and never run interception experiments against accounts or systems you do not control.
Troubleshoot without exposing tokens
Log a synthetic transaction identifier, the stage that failed, and a redacted error category. Do not log the code, verifier, access token, refresh token, or complete callback URL. A useful support trace describes the mismatch without preserving reusable secrets.
For an encoding failure, compare the transformation using a synthetic verifier. For a state failure, inspect the transaction storage and expiry behavior. For an issuer or redirect error, review registered configuration rather than weakening validation to make the test pass.
An authorization server may return an error that sounds similar across several problems. Keep your local evidence narrow: which stage was reached, what non-secret configuration was selected, and whether the attempt was already consumed. Avoid guessing that the provider is broken from one generic error response.
Review deployment changes
Before changing callback domains, proxy settings, or client configuration, run the same negative tests against the new environment. A reverse proxy can affect the externally visible redirect URI, so confirm the intended public value without trusting arbitrary forwarded headers.
Our OAuth consent audit guide covers granted application access rather than PKCE mechanics. Text Diff can help review redacted client configuration changes, but it cannot validate an OAuth flow or authenticate a token.
Conclusion
Correct PKCE requires a fresh verifier, the matching S256 challenge, and reliable per-flow handling. Its value is strongest when the surrounding callback, issuer, replay, and application authorization checks remain intact and are tested explicitly.