The OAuth flow has four participants: client, authorization server, resource server, and, when state is absent, the attacker. RFC 6749 made the only CSRF control in the authorization code flow optional. This is not theoretical. It is CVE-2019-10315, CVE-2023-31999, and most OAuth clients running in production today. State Binds the Flow to the Session. RFC 6749 Made It Optional The state parameter is the only mechanism that ties an OAuth callback to the session that initiated it. Without it, any authorization response can be delivered to any session. RFC 6749 §4.1.1 lists state as OPTIONAL in the authorization request. Section 10.12 says clients SHOULD use state to protect against CSRF (SHOULD, not MUST). RFC 9700, published in January 2025, corrected this: clients MUST use state or PKCE for CSRF protection. Most OAuth libraries and existing documentation were written against RFC 6749, before that update. A 2016 study found that 61% of OAuth-deploying websites skipped CSRF countermeasures entirely. The gap between recommendation and production behavior is where the vulnerability lives. The Attack: Stopping the Flow at the Right Moment The attack exploits the decoupled nature of OAuth's front channel. The attacker initiates the flow and captures the authorization URL before the callback fires. Then they deliver that URL to the victim, who completes it in their own authenticated session. Step by step: The attacker initiates an OAuth flow on the victim application using their own browser. They receive the authorization URL: https://idp.example/authorize?client_id=X&redirect_uri=Y&state=Z They stop before clicking "Authorize." No callback has fired yet. They send the authorization URL to the victim via phishing email, iframe, or embedded link. The victim clicks, authenticates with the IdP, and receives the redirect to Y?code=A&state=Z. The victim's browser completes the callback. The authorization code is exchanged and the IdP account is now linked to the attacker's session in the application. The attacker never needed the victim's password. They needed the victim to visit a URL. The delivery vector can be anything: a phishing email, an iframe on an attacker-controlled page, or a forged product notification. Forced Account Connection: Identity Hijack, Not Credential Theft The outcome distinguishes OAuth CSRF from classic CSRF. The attacker's OAuth identity gets attached to the victim's account. From that point, the attacker authenticates as the victim using their own IdP credentials, without ever knowing the victim's password. CVE-2019-10315 (Jenkins GitHub Auth Plugin, April 2019): the plugin did not use the state parameter. The attacker initiated the OAuth flow with GitHub, stopped before the redirect, and sent the authorization URL to a Jenkins administrator. When the admin clicked, their GitHub account was linked to the attacker's Jenkins session. The attacker then logged into Jenkins with their own GitHub credentials and obtained the victim admin's privileges. The victim's password remains unchanged. The attacker's access persists until the OAuth connection is manually removed. There is no credential theft. There is identity routing. PKCE Does Not Fix This PKCE (RFC 7636) prevents authorization code interception. An attacker who intercepts code=A cannot exchange it without the matching code_verifier. In forced account connection, the victim's browser legitimately exchanges the code. The victim has the code_verifier because their browser initiated the exchange. PKCE validates code legitimacy. It does not verify whether the authorization request was initiated by the current user's session. Only state does that. The two controls are orthogonal: state checks session origin, PKCE checks code integrity. RFC 9700 §4.7.1 is explicit: clients MAY rely solely on PKCE only when they have verified that the authorization server enforces it. Deploying PKCE in production without state does not eliminate CSRF exposure. Documentation from several providers presents PKCE as a general security replacement, creating exactly that confusion. Three CVEs, Three Failure Modes Three confirmed CVEs show that optional state ships disabled, shared, or unvalidated in production OAuth libraries. The common pattern is not absence. It is invisible failure. Absent: CVE-2019-10315 (Jenkins GitHub Auth Plugin <=0.31). State omitted entirely. Forced account linking possible for any authenticated user. Fixed in version 0.32. Static shared: CVE-2023-31999 (@fastify/oauth2 < 7.2.0). State was a static value generated at startup, shared across all requests and all users. Any cross-site request passed validation because the expected value was always the same. CVSS 7.6. Fixed in v7.2.0 with per-user state in an HttpOnly cookie with SameSite=Lax. Present but unvalidated: CVE-2018-20595 (hsweb 3.0.4). State was generated and sent in the authorization request, but the callback handler never compared the received value against the session value. The check existed in the code and did not work. All three failure modes produce equivalent exposure. CVE-2023-31999 is the most instructive: state was present in the protocol, but the absence of per-session randomness made the protection worthless. Entropy Requirements: Sequential State Is No Better Than No State A sequential or predictable state value provides no protection. An attacker who can guess or enumerate state values constructs valid authorization URLs. OWASP ASVS V3.5 requires at least 128 bits of entropy for cryptographic tokens. Correct generation produces 256 bits: // Node.js const state = crypto.randomBytes(32).toString('base64url'); req.session.oauthState = state; # Python import secrets state = secrets.token_urlsafe(32) session['oauth_state'] = state Store in server-side session at authorization request time. On callback, use constant-time comparison: // Node.js const valid = crypto.timingSafeEqual( Buffer.from(req.query.state), Buffer.from(req.session.oauthState) ); if (!valid) return res.status(400).send('Invalid state'); # Python import hmac valid = hmac.compare_digest(received_state, session['oauth_state']) if not valid: abort(400) Invalidate state after single use. State must not persist across multiple authorization flows. A reusable state creates an attack window even when generation is cryptographically correct. Detection and Remediation: Code Review Signals to RFC 9700 Compliance Code review signal: OAuth callback handlers that extract req.query.code without first validating req.query.state against the session value. Also: any comparison of state against a configuration-level constant (the CVE-2023-31999 pattern). Black-box test 1: initiate an OAuth flow, capture the callback URL, and replay it from a different session. If the login completes, state is not being validated. Black-box test 2: remove the state parameter from the authorization URL. If the callback completes without error, validation is absent. The fix is straightforward. Generate cryptographically random state per flow and store it in server-side session. Validate with constant-time comparison before processing the code. Reject with HTTP 400 any callback with absent or mismatched state. OAuth State Validation in MAGO Intel The MAGO Intel tool (intel.mago.team) checks OAuth state for all three failure modes in API security assessments. Coverage includes absent state generation, static or low-entropy values, and callback handlers that accept requests without validating. Findings are mapped to the CVE-2019-10315 and CVE-2023-31999 patterns in structured reports. RFC 6749 created a decade of optional CSRF protection. RFC 9700 closes the gap, but only for code written after January 2025. Every OAuth client deployed before that date carries a forced account connection waiting to be triggered.