ASVS 5.0 V10.4.6 (Level 2) requires an authorization server that supports the authorization code grant to enforce PKCE, short for Proof Key for Code Exchange. Every authorization request must carry a valid code_challenge. The method plain must be refused, so only S256 is accepted. And every token request must include a code_verifier that the server checks.
The word that matters is "require". Supporting PKCE for clients that opt in does not meet the requirement. The server has to refuse requests that leave it out.
Why it matters
The authorization code travels through the front channel, and on mobile and desktop systems another app can register the same redirect scheme and grab it. Without PKCE, whoever holds the code can redeem it.
PKCE ties the code to a secret that only the original client knows. The client generates a random verifier, sends a hash of it (the challenge) when starting the flow, and sends the raw verifier when redeeming the code. An attacker who steals the code has no verifier, so the token request fails.
The plain method sends the verifier itself as the challenge. An attacker who can see the authorization request sees the secret too, so the protection disappears. That is why S256 only is the rule.
PKCE also helps confidential clients, because it blocks code injection, where an attacker feeds a stolen code to a victim's session.
How to implement it
On the authorization endpoint, reject requests that lack a challenge or ask for plain.
import base64, hashlib, hmac, re
VERIFIER_RE = re.compile(r"^[A-Za-z0-9\-._~]{43,128}$")
CHALLENGE_RE = re.compile(r"^[A-Za-z0-9\-_]{43}$")
def validate_authorization_request(params):
if params.get("response_type") == "code":
challenge = params.get("code_challenge")
method = params.get("code_challenge_method")
if not challenge or not CHALLENGE_RE.match(challenge):
raise InvalidRequest("code_challenge required")
if method != "S256":
raise InvalidRequest("only S256 is supported")
At the token endpoint, hash the verifier and compare in constant time.
def verify_pkce(stored_challenge, code_verifier):
if not code_verifier or not VERIFIER_RE.match(code_verifier):
raise InvalidGrant()
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
if not hmac.compare_digest(computed, stored_challenge):
raise InvalidGrant()
If the authorization request had a challenge, the token request must have a verifier. Do not let a client skip the verifier because it did not "have to" use PKCE.
On the client side, here is a correct generator in JavaScript:
function b64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function makePkce() {
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return { verifier, challenge: b64url(new Uint8Array(hash)) };
}
Most server products have a switch. In Keycloak, set "Proof Key for Code Exchange Code Challenge Method" to S256 on each client. Many identity providers have a tenant wide setting too.
Enforcing it globally
Configure PKCE as a server wide default, not a per-client opt in, so a newly registered client cannot forget it. Where your product supports it, also advertise code_challenge_methods_supported as S256 only in discovery metadata, so well behaved clients do the right thing without trial and error.
Bind the challenge to the authorization code when you issue it. When the token request arrives, load the stored challenge for that code, and fail if the request has no verifier or if the hash does not match. Do not look up the challenge by client ID alone, or one client's verifier could unlock another client's code.
How to test it
Send each of these to the authorization endpoint and record the result:
- A code flow request with no
code_challenge. Expect an error redirect or response. - A request with
code_challenge_method=plain. Expect an error. - A valid S256 request. Expect success.
Then complete the valid flow but alter the verifier at the token endpoint. Expect invalid_grant. Omit the verifier entirely. Expect invalid_grant. Finally, use the correct verifier and confirm the tokens arrive.
A dynamic scanner can also probe this for you. The demo below shows what a dynamic web app scan looks like in practice.
Click through it at your own pace, or open it full screen.
Automating the check
# Expect an error: no code_challenge
curl -si "https://auth.example.com/authorize?response_type=code&client_id=demo&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb&scope=openid" | head -n 5
# Expect an error: plain method
curl -si "https://auth.example.com/authorize?response_type=code&client_id=demo&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb&code_challenge=abc&code_challenge_method=plain" | head -n 5
Wrap these in a CI job against a staging server. When the response is a redirect, check that the error parameter on the redirect URI is invalid_request.
Edge cases worth a look
Some frameworks generate the verifier and challenge for you, and some let you pass your own. If you pass your own, check the length limits: a verifier is 43 to 128 characters from a defined set, and a challenge for S256 is 43 characters of base64url without padding. Servers that skip these checks accept junk values that make tests pass for the wrong reasons.
Also check what happens with a client that sends a challenge but a verifier of a different length, with extra whitespace, or with padding characters. Each of these should fail without a stack trace.
For native apps, the redirect URI and PKCE work together. Prefer claimed https links or loopback redirects over custom schemes where the platform allows it, and keep PKCE on regardless.
Common mistakes
- Advertising PKCE support in discovery metadata but not enforcing it.
- Accepting
plainfor older clients. Upgrade the clients. - Generating the verifier with
Math.randomor a short string. Use a cryptographic random source and at least 32 bytes. - Storing the verifier in the URL or in local storage that other scripts can read.
- Accepting a token request without a verifier when the original request had a challenge.
Quick check
Why is the plain method rejected?
It sends the verifier as the challenge, so anyone who can see the authorization request learns the secret and can redeem a stolen code.
Does PKCE only matter for public clients?
No. It also blocks code injection against confidential clients, and current guidance recommends it for all clients using the code grant.