← Back to Attack Research

OAuth state-parameter exploitation: how attackers turn a missing nonce into account takeover

The state parameter is OAuth's CSRF token. Most teams treat it as boilerplate, copy a sample value, and ship. Attackers know this. We walk the four-step exploit, the recent in-the-wild campaigns, and the validation contract that closes it permanently.

An attacker initiates an OAuth login at your service. They abandon the flow halfway, capturing the authorisation code your IdP issued. They send the victim a link that completes their flow, with their code, in the victim's browser. Your service binds the attacker's identity to the victim's session. The victim is now logged in as the attacker, into their own bank, into their own healthcare portal, into their own customer dashboard. The state parameter exists specifically to stop this. Most teams ship it as a hard-coded string, a constant, or with no validation on the return leg. Microsoft's March 2026 advisory documents the campaign that operationalised this for phishing. The fix is a contract every OAuth client must satisfy. We walk it below.

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:

  1. 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.
  2. 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.
  3. 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

ATTACKER DECISION TREE OAuth State Parameter ┌──────────────────────────────────────┐ │ 1. Initiate OAuth login at target │ │ - hit /auth/login │ │ - capture redirect to IdP │ │ - inspect outbound state value │ │ a) absent → variant A │ │ b) hardcoded → variant B │ │ c) random per request → next │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 2. Complete IdP auth as attacker │ │ - log in legitimately │ │ - intercept callback URL │ │ - extract code + state │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 3. Probe the validation │ │ - replay callback with same │ │ code, modified state │ │ - reordered params │ │ - omitted state │ │ - inflated state │ │ - JWT-encoded state w/ no sig │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 4. Weaponise: phishing or CSRF │ │ - send victim a callback URL │ │ containing attacker's code │ │ - victim clicks, victim's │ │ browser completes flow │ │ - victim now logged in as │ │ attacker → identity-binding │ │ account takeover │ └──────────────────────────────────────┘

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:

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

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.

Find

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.

Prove

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.

Fix

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.

Verify

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

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 →