Authentication Published Oct 29, 2024

OAuth-Based CSRF: Exploiting The Flaw In Implementation Of State Parameter

The target app had a state parameter on its OAuth flow, but it just happened to be the same string for every user, every session. One copied URL later, we were linked to the victim's account and able to sign in as them.

OAuth-Based CSRF: Exploiting the Flaw in Implementation of the State Parameter

During a recent engagement, our team uncovered a subtle but high-impact flaw in how an otherwise mature product had wired up its "Connect with Facebook" flow. On paper, the OAuth handshake looked correct: scopes were minimal, redirects were validated, and the state parameter was present on every request. In practice, that state was not actually doing what OAuth 2.0 requires of it, and the result was a clean CSRF primitive that let us hijack a victim's account with nothing more than a crafted link.

This write-up walks through how the bug was discovered, why the state parameter matters, and what a correct implementation should look like.

A Quick Refresher On OAuth 2.0

OAuth 2.0 is the delegation protocol that powers most "Sign in with…" and "Connect your account" flows on the modern web. When a user clicks Continue with Facebook, the application (the client) redirects the browser to Facebook (the authorization server), which authenticates the user and, after consent, redirects back to the application with a short-lived authorization code. The application then swaps that code for an access token it can use on the user's behalf.

OAuth 2.0 authorization-code flow between user, client and authorization server

An authorization request typically looks like this:

GET /dialog/oauth
  ?response_type=code
  &client_id=CLIENT_ID
  &redirect_uri=https%3A%2F%2Fredacted.com%2Fauth%2Ffacebook%2Fcallback
  &scope=email,public_profile
  &state=OPAQUE_VALUE_TIED_TO_THE_USER_SESSION
Host: www.facebook.com

The state parameter is the protocol's built-in CSRF defence. The client generates an unpredictable value, binds it to the current user's session (for example, stores it in a cookie or server-side session), and includes it in the authorization request. When the authorization server redirects the user back, the client verifies that the returned state matches the one it issued for this session. If the values don't line up, the callback is rejected.

Where The Implementation Went Wrong

While intercepting the "Connect with Facebook" flow in Burp Suite, we captured the authorization request the client was sending to Facebook. The state parameter was present, URL-encoded, and looked superficially random. Decoding it, however, revealed a very different picture:

Captured Connect with Facebook authorization request in Burp Suite
{"wt":"134","app":"login","connect":true,"next":"https:\/\/redacted.com"}

That was it. The "CSRF token" was a JSON blob describing the intent of the request (which app, which tenant, where to send the user next) with no per-session entropy whatsoever. We logged out, logged back in from a fresh browser, and watched the same string be issued again. Tested across multiple accounts, the state value was identical for every user initiating a Facebook connection.

Decoded state parameter showing no per-session entropy

Once that fact is established, the rest is mechanical. The callback handler on the application side was comparing a value that would always match, regardless of who initiated the flow. In effect, the CSRF defence had been turned off, while still appearing to be on.

Turning The Flaw Into An Account Takeover

The application let users link a Facebook identity to their existing account so they could sign in with either credential set going forward. That combination, (a) a stable OAuth URL that any browser could complete, and (b) the ability to attach a new identity provider to an existing account, is what turns a CSRF into a full takeover. The attack shape:

  1. The attacker begins their own "Connect Facebook" flow and intercepts the redirect to facebook.com/dialog/oauth?....
  2. They copy the authorization URL, including the static state parameter, and stop the flow before completing it.
  3. They embed that URL in a phishing page, iframe, or even a simple <img>/fetch triggered by the victim's browser while authenticated to the target application.
  4. The victim's browser follows the URL. Because they are already logged into the target application, the subsequent callback is processed as if the victim had initiated the flow.
  5. The server validates the returned state (which matches, because it is the same for everyone) and dutifully attaches the attacker's Facebook account to the victim's user record.
  6. From that moment on, the attacker can sign in with their own Facebook credentials and land inside the victim's account.
Attacker copies the authorization URL from their own session
Crafted link delivered to the victim while they are authenticated
Victim's browser completes the OAuth callback
Application links the attacker's Facebook identity to the victim's profile
Attacker signs in with their Facebook credentials, lands in the victim's account

There is no password prompt, no email confirmation, and no secondary check, because from the application's point of view the victim clicked Connect with Facebook and everything validated. The UX success flow and the attack flow are indistinguishable on the server side.

Victim's account dashboard viewed by the attacker post-takeover
Post-exploitation: attacker has full access to sensitive actions

Why This Keeps Happening

The team that built this flow was not careless. They added a state parameter, signed it, and bound it to "the request". The mistake was a subtle confusion of two different ideas:

  • State as transport metadata: carrying context through the OAuth round-trip ("this request came from the login page, remember to send them to /dashboard afterwards"). Totally fine. Predictable by design.
  • State as a CSRF token: binding this specific authorization request to this specific browser session so an attacker cannot make a different session complete it. Must be unpredictable, per-request, and server-validated.

If you only do the first, you get what we found: a parameter that looks like a CSRF defence and satisfies reviewers skimming the request, but fails the one test that matters: that it be unguessable and session-bound.

What A Correct Implementation Looks Like

  1. Generate a cryptographically random state per authorization request. 128 bits of entropy from a CSPRNG is more than enough. If you need to carry business context through the flow, do it in a separately signed blob. Do not conflate the two.
  2. Bind the state to the initiating session. Store it in a session cookie, signed JWT, or server-side session: something only that browser can present back.
  3. Validate constant-time on return. Reject the callback if state is missing, does not decode, or does not match the value bound to the current session. Clear it after one use.
  4. Prefer PKCE even for confidential clients. It costs nothing and closes an adjacent class of authorization-code interception bugs.
  5. Never auto-link identities without an explicit confirmation step. If a callback is going to attach a new identity provider to an existing account, require the user to re-authenticate or confirm via a signed email link. This is a cheap, effective defence-in-depth control even if your state handling is perfect.

Conclusion

The presence of a state parameter does not mean a site is safe from OAuth-based CSRF. If the value is static, shared, or otherwise guessable, it is cosmetic, and a cosmetic CSRF defence is worse than no defence, because it creates false confidence during review. Treat state like any other security token: generate it with a CSPRNG, bind it to the session, validate it on return, and burn it after a single use.

Have thoughts on OAuth hardening or a war story of your own? We would love to hear from you on LinkedIn.