This is the third piece in our attack-research series. The first walked SSRF into cloud metadata. The second walked JWT alg-confusion. This one walks OAuth state-parameter abuse — a class that is structurally similar (the protocol gives the attacker a lever; the integration is supposed to remove it; the integration usually does not) but with a different attacker decision tree and a different fix-list. By the end you should be able to grep your code for OAuth client implementations and confirm each satisfies the validation contract. Verifiable security.
The attack pattern in one paragraph
OAuth 2.0's authorisation code flow has the user bounce from your service ("Relying Party") to an Identity Provider ("IdP"; could be Google, Microsoft, Okta, your own SSO, anything), then back. On the way out, your service sends a request like https://idp.example/authorize?client_id=...&redirect_uri=...&response_type=code&scope=...&state=<nonce>. On the way back, the IdP redirects the user's browser to https://your.example/callback?code=...&state=<nonce>. Your service exchanges the code for tokens, completes login, and seats the user.
The state parameter is a cryptographic nonce your service generated at the start of the flow and stashed in the user's session. On the return leg, your service is supposed to confirm the returned state matches the one in the user's session before completing login. If you do not, anyone who possesses a valid code can finish the flow in someone else's browser. The attack mechanic is: attacker starts an OAuth flow, captures a valid code issued for their identity, crafts a callback URL containing that code plus an arbitrary state value, sends the URL to the victim, victim clicks, victim's browser completes the flow, victim's session is now bound to the attacker's identity at the IdP. The victim does not see anything wrong — they are still on your service's domain. They are just logged in as somebody else, looking at somebody else's data, capable of taking actions that get attributed to somebody else.
The class has multiple variants. State omitted: the request and callback never include a state parameter, so any code can complete any flow. State present but unvalidated: the callback echoes whatever the attacker sent, and the server proceeds. State validated by length only: the server checks len(state) > 0, accepting any non-empty value. State validated against a global value, not session-bound: the server confirms state == <hardcoded>, which the attacker fetches once. State expected to "embed" data (encoded JSON, JWT, signed token) but with weak verification: documented in the wild as recently as last quarter.
Why this still ships in 2026
Three structural reasons:
- The OAuth specification originally said state was optional. RFC 6749 (2012) lists state as RECOMMENDED, not REQUIRED. Many integrations are still operating on framework boilerplate from 2014, 2015, 2016 that omits state because the spec said it was optional and the IdP did not enforce it. RFC 9700 (BCP 240, "OAuth 2.0 Security Best Current Practice," published January 2025) explicitly requires state, but as best practice, not as protocol-mandatory.
- The integration code lives at the boundary between two ecosystems. The IdP team owns one half of the flow; the application team owns the other. Whose responsibility is the state validation? Both teams' answer is "the other one." The result is integrations where neither side actually enforces.
- The bug is invisible in normal operation. Unlike a 500-error or a failing test, "we accept state values we should reject" never surfaces in product telemetry. The flow works for normal users. The bug only surfaces when an attacker actively exploits it. Static analysis catches some variants; nothing in the build pipeline catches all of them.
The attacker decision tree
The four-step decision tree an attacker walks against any OAuth-using service.
The phishing payload at step 4 is the part that makes this dangerous at scale. A vanilla CSRF would require the victim to actively complete an action; the OAuth-state attack only requires the victim to click. The URL looks like https://your.service/callback?code=<valid>&state=<valid-or-bypassed>. To the victim, it is a normal-looking SSO callback. They click, their browser hits the callback, your service completes the login, they are now seated as a user that is not them.
A composite real-world scenario
The setting is a B2B SaaS in financial-services analytics — the kind of platform where users have read access to their own institution's accounting general ledger, and admin users can pull data exports. The platform does SSO via Okta. The OAuth integration was written in 2018, by a contractor, and has been maintained by three different engineers since.
An attacker registers a free-trial account in the platform with a throwaway email at a domain they control. Okta auths them in, the platform seats them as a user of org_id: TRIAL-882913. They inspect the OAuth flow in dev tools. The outbound request to Okta includes state=signupflow — a literal string, the same value for every user, every flow, since the contractor copied it from a tutorial in 2018 and never wired up randomness. Variant B.
The attacker now has everything they need. They craft a phishing email styled as a "session refresh" notification from the platform — legitimate-looking, well-targeted, with the platform's logo and a link to a "session-renewal" URL. The link is the platform's own callback endpoint, with the attacker's previously-captured code and the literal string state=signupflow. They send the email to the platform's CFO, whose name they pulled off LinkedIn.
The CFO clicks. Their browser hits the callback. The platform's server reads the code, exchanges it with Okta for tokens (Okta returns the attacker's identity, since the code was issued for the attacker's authentication), reads the state, confirms it equals signupflow, and seats the CFO's browser as the attacker's user. The CFO is now logged into the trial account. They are confused for about thirty seconds; they assume there has been a login mix-up and log out, then log in again. The platform's audit log records the brief confused session as "user TRIAL-882913 accessed CFO's IP for 28 seconds." Nobody investigates; nothing alarming happened.
The attacker, meanwhile, now has the CFO's session cookie (issued during the brief authenticated period) because the callback URL was crafted to also leak the cookie via a same-site fetch to attacker infrastructure. They wait until evening, replay the cookie, and pull the data exports. The whole chain takes about ninety seconds of victim-side activity, plus six hours of preparation.
The fix on the platform side is one method change: bind the state value to the user's session at flow initiation, store it with a short TTL, and reject any callback whose state does not match the session-bound value. Three lines of code.
What we observe in customer environments
Across our continuous validation runs against customer-flagged OAuth-using endpoints in the past nine months, the rough breakdown:
- Roughly one in twelve OAuth integrations sent no state value at all. These are typically older B2B SaaS, internal tools that "did not need it," and one-off integrations that nobody owns end-to-end.
- Roughly one in five integrations sent a state value but did not validate it on return. Variants observed: validation present in dev environment but stripped in production by a feature-flag gate; validation present at the route handler but bypassed by a middleware shortcut; validation present but the validator returned
truefor empty strings. - Roughly one in eight integrations bound the state value to a global constant rather than the session. These are the highest-leverage targets — one fetched value, weaponisable against every user.
- Roughly half of the integrations we audited that did validate state correctly were also missing PKCE on public-client flows, which is a related (but distinct) class of weakness.
Microsoft's April 2026 advisory on AI-enabled device-code phishing documents that the threat actors operationalising these flows have moved from manual crafting to automated, AI-assisted infrastructure that bypasses the standard 15-minute expiration window through dynamic code generation. The attack class is moving up the sophistication ladder; the defensive posture in the integrations we audit has not kept pace.
What to do about it — the OAuth-client validation contract
OAuth client validation contract — eight controls that close the class
- Generate state as a cryptographic random value at flow initiation, minimum 128 bits of entropy. Use
crypto.randomBytes(16),secrets.token_urlsafe(16), or the equivalent in your language's secure-random API. Never useMath.random()or session ID derivatives. - Bind the generated state to the user's session immediately, with a short TTL. Five to fifteen minutes is appropriate. Persist server-side; do not rely on the client to round-trip it without validation.
- On callback, reject any request whose state value does not match the session-bound value. Reject with a 4xx error and a generic "invalid request" message. Do not echo back which validation failed.
- Single-use the state value: invalidate it after consumption. Once you have validated a callback's state and started exchanging the code for tokens, the state value is dead. A second callback with the same state must fail.
- Use PKCE on every public-client flow. RFC 9700 requires PKCE for public clients. PKCE binds the code-issuance to the original authentication context cryptographically, which closes a related class of code-injection attacks even when state is present and validated.
- Validate the redirect_uri at the IdP-registration level with exact match. RFC 9700 explicitly requires this. No prefix matching, no scheme-only matching, no domain-only matching. The exact path must match the value registered with the IdP.
- Reject callbacks where the state is encoded JSON or JWT but unsigned. If your application embeds metadata in the state value (a tempting but rarely-needed pattern), the embedded data must be signed and the signature must be verified. Treat unsigned state-as-JSON as "no state at all."
- Audit log every OAuth callback with state-validation outcome. Successful, failed-mismatch, failed-missing, failed-replay. Alert on any state-validation failure rate above the steady-state baseline.
The state parameter is OAuth's CSRF token. Treat it like a CSRF token. Generate per-request, bind to session, single-use, validate on return. There is no clever way to do less.
How Celvex catches this
Find. Prove. Fix. Verify.
The scanner identifies OAuth-using endpoints by callback-URL pattern, captures the outbound state generation behaviour across multiple flow initiations, and probes the four state-validation variants.
For confirmed exposures, we ship a Proof Capsule with the captured codes, the crafted callback URL, and a replay script that demonstrates the cross-session bypass against the customer's own staging.
The Capsule's remediation block points at the eight-item validation contract scoped to the OAuth client library and version: which method to add, which middleware to insert, which IdP-registration setting to tighten.
After the fix lands, the replay flow returns 4xx instead of completing the login. The finding closes. The dashboard records the verified-fix event for the auditor.
Where we sit on the autonomy curve: at L1.5 today, the scanner has a tagged corpus for the four primary state-validation variants and probes them against any OAuth callback our customers expose. At L2 in 90 days, the corpus extends to PKCE-omission, redirect_uri-bypass, and state-as-unsigned-JWT variants. At L3 in twelve months, the scanner mutates probes to fit unfamiliar OAuth profiles (private OIDC, custom SSO, federation through proxy IdPs). We are not at L3 today. We are at L1.5 and shipping.
The Proof Capsule for an OAuth state finding includes a working callback URL the customer's engineer can paste into a private browser to demonstrate the bypass, plus the exact session-bound-state validation snippet to add. Run it. Watch the bypass succeed. Apply the fix. Re-run. Watch the bypass fail. Find. Prove. Fix. Verify.
Bottom line
OAuth state-parameter exploitation is the OAuth equivalent of "we forgot to set the CSRF token cookie" — a primitive whose entire purpose is to stop one specific attack, deployed in production with the configuration that lets the attack through. The fix is mechanical, well-documented in RFC 9700, and finishable in an afternoon for any single integration. Until that afternoon, every OAuth-using endpoint in your stack is one phishing-grade URL away from cross-session account takeover.
Verifiable security. Find it. Prove it. Fix it. Verify it held. That is what we ship.
Sources
- Microsoft Security Blog — OAuth redirection abuse enables phishing and malware delivery (March 2026)
- Help Net Security — AI-enabled device-code phishing campaign exploits OAuth flow for account takeover (April 2026)
- IETF RFC 9700 (BCP 240) — OAuth 2.0 Security Best Current Practice (January 2025)
- Doyensec — Common OAuth Vulnerabilities
- Salt Labs — New OAuth Vulnerability Impacts Hundreds of Online Services
- PortSwigger — OAuth 2.0 authentication vulnerabilities
- CyberArk — How Secure Is Your OAuth? Insights from 100 Websites
- CELVEX Group — Proof Capsule format
See your OAuth flows the way an attacker does.
Free Exposure Check — sixty seconds, no signup. We probe every public-facing OAuth callback against the four state-validation variants and ship a signed Proof Capsule for the highest-confidence finding.
Run a Free Scan →