ASVS 5.0 V10.2.1 requires that an OAuth client using the authorization code flow is protected against browser based request forgery (CSRF) that triggers token requests. You meet it with proof key for code exchange (PKCE), by checking the state parameter sent in the authorization request, or both. It is a Level 2 requirement.
Why it matters
The redirect URI of your client is a URL anyone can send a browser to. If the callback accepts any code that arrives, an attacker can feed a victim's browser a code from the attacker's own authorization, a login CSRF. The victim then operates inside the attacker's account, and things they upload or link, such as payment details or files, land where the attacker can read them. In other variants the attacker injects a stolen code to hijack the exchange.
How to implement it
Do both PKCE and state. PKCE binds the code to a secret only your client knows, and state ties the callback to the browser session that began the flow. Current OAuth security guidance recommends PKCE for all clients, including confidential ones.
import secrets, hashlib, base64
from flask import session, redirect, request, abort
def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
@app.get("/login")
def login():
state = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(64)
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
session["oauth_state"], session["oauth_verifier"] = state, verifier
return redirect(build_auth_url(state=state, code_challenge=challenge,
code_challenge_method="S256"))
@app.get("/callback")
def callback():
expected = session.pop("oauth_state", None)
verifier = session.pop("oauth_verifier", None)
got = request.args.get("state", "")
if not expected or not verifier or not secrets.compare_digest(expected, got):
abort(400)
return exchange_code(request.args["code"], code_verifier=verifier)
Points that matter: generate with a cryptographic random source, use S256 and never plain, remove the values on first use, and reject callbacks that have no stored state at all.
On the authorization server side, require PKCE for public clients and enforce that a token request with a code_verifier matches the original challenge.
How to test it
- Start a login and capture the callback URL. Open it in a second browser with no session. Expect a 400 and no login.
- Edit one character of
state. Expect rejection. - Remove the
stateparameter. Expect rejection. - Complete a login, then replay the same callback. Expect failure because the stored values were consumed.
- Watch the token request and confirm it contains a
code_verifier. - A DAST pass over the login and callback endpoints is a useful cross check, and the DAST demo shows what that run looks like.
Common mistakes
- Treating
stateas optional or accepting an empty one. - Comparing
stateagainst a value stored in a cookie that is not tied to the session. - Using
code_challenge_method=plain. - Fixed or predictable verifiers.
- Handling the callback without a session at all in a stateless service and skipping the check.
Try it yourself
Click through it at your own pace, or open it full screen.
Quick check
Is PKCE alone enough for V10.2.1?
The requirement allows PKCE or a checked state parameter. Using both gives overlapping protection.
What does the callback do if there is no stored state?
It rejects the request. A callback with nothing to compare against was not started by this session.