Safeguard
Application Security•

ASVS 5.0 V10.2.1: Protect the OAuth Code Flow from CSRF

ASVS V10.2.1 (Level 2): an OAuth client using the code flow must defend against login CSRF with PKCE or a checked state parameter. Code and test steps.

Safeguard Security Team
3 min read

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

  1. Start a login and capture the callback URL. Open it in a second browser with no session. Expect a 400 and no login.
  2. Edit one character of state. Expect rejection.
  3. Remove the state parameter. Expect rejection.
  4. Complete a login, then replay the same callback. Expect failure because the stored values were consumed.
  5. Watch the token request and confirm it contains a code_verifier.
  6. 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 state as optional or accepting an empty one.
  • Comparing state against 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.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.