Skip to main content
Back to the shipping log
Security9 min read

Three OAuth state-bypass patterns we keep finding in the wild

Three OAuth state-handling bugs we reported on bug-bounty programs last quarter. All three were exploitable. Here's the pattern, the fix, and why CSRF tokens alone aren't enough.

HTThe Hayaiti team
#security#oauth#bug-bounty

Built on tools you trust

Vercel
Stripe
Cloudflare
GitHub
Linear
Slack
Resend
Sentry
Postgres
PostHog
Loom
Notion

← swipe · 12 tools →

A note before we start

Nothing in this post is novel. The OAuth state parameter has been documented as a CSRF defense since RFC 6749 in 2012, and the failure modes below are in every OWASP guide. We keep finding them anyway in public bug-bounty engagements run by the founders. This is a write-up of three patterns reported on in the last quarter, with the fix patterns that worked.

Background: what state is for

The OAuth 2.0 authorization code flow involves a redirect from the relying party (your app) to an identity provider (Google, GitHub, Microsoft, whoever), then a callback redirect back to your app with an authorization code.

Without protection, an attacker can craft a callback to your app with *their* authorization code. If your callback handler exchanges the code and binds the resulting session to whoever's browser made the callback, the attacker has logged a victim into the attacker's account. Variations of this enable account hijacking, data exfiltration, and a category of CSRF.

The defense is the state parameter: a cryptographically random value the relying party generates before redirecting to the IdP, sends along, and verifies on the callback. If the state doesn't match what was generated, reject the callback.

That's the theory. Here's what we keep finding.

Pattern 1: state is missing entirely

The simplest case. The app redirects to the IdP with no state parameter at all. The callback handler accepts whatever code shows up.

The founders flagged this on three different SaaS apps via bug-bounty last quarter. In two of them, the developer had configured state for the production OAuth provider but not for a secondary provider (e.g., "Sign in with Microsoft" was secured, "Sign in with Slack" wasn't).

Why it happens: OAuth client libraries usually generate state automatically when configured correctly. When developers wire OAuth manually, or override the library defaults, or copy-paste from a stale example, the state can quietly drop out.

Detection: Open dev tools, click "Sign in with X", look at the URL. If there's no state= parameter, the flow is unprotected. That's it.

Fix: Use the OAuth library properly. If you've written it yourself:

  • Generate a 32-byte random value (crypto.randomBytes(32) / secrets.token_urlsafe(32)).
  • Store it server-side (session, signed cookie, or short-lived Redis key) keyed to the user's session.
  • Send as the state parameter in the auth URL.
  • On callback, verify the returned state matches the stored value *before* exchanging the code.
  • Delete the stored state after verification (single-use).

Pattern 2: state is predictable

This one's slightly more sophisticated. The state is present, looks random-ish, but isn't.

Examples we've reported:

  • State derived from the user's session ID (which is itself accessible to the app via cookies).
  • State derived from a timestamp + a low-entropy hash.
  • State that's the same value every time for a given user.
  • State generated by Math.random() instead of a CSPRNG.

Why it's exploitable: if the attacker can predict the victim's state value, they can craft a callback URL that passes the state check. The protection is bypassed.

Detection: request the OAuth flow several times for the same user (with the same session). If the state value is identical, or if it shifts in a predictable way (timestamp, counter, hashed session ID), it's predictable.

Fix: Use a CSPRNG. In Node, crypto.randomBytes. In Python, secrets. In Go, crypto/rand. Don't roll your own. Don't reuse state across requests. Don't derive state from anything the attacker can observe.

Pattern 3: state is stored client-side without integrity

Some libraries try to be helpful by storing the state in a cookie or in the OAuth flow's PKCE-equivalent fields, then checking on callback. If the storage mechanism doesn't bind the state to the user's session *and* protect it from tampering, the check is meaningless.

The pattern we found three times:

  • State is generated and sent to the IdP.
  • A copy of the state is stored in a cookie named something like oauth_state, *unsigned*.
  • On callback, the handler compares req.query.state to the cookie.

Since both values are attacker-controlled (the URL is theirs, the cookie can be set via injection or a parallel session), they trivially match.

Why it happens: developers know they need to store state somewhere and the cookie-jar pattern feels symmetric (state in URL, state in cookie, compare). It's broken because cookies are not a trust boundary against the same browser.

Fix: the storage must be either:

  • Server-side, keyed to the authenticated session. The browser doesn't see it. The callback handler looks it up from the session.
  • Signed. A cookie containing state || HMAC(server_secret, state), verified on callback. Even if the user can read the cookie, they can't forge a new state.

PKCE solves a related problem (code interception by malicious apps in mobile/native flows) but doesn't replace state. Use both.

What this all has in common

In all three patterns, the OAuth implementation passes a casual code review. There's a state parameter. There's a check. The flow looks right. The bug is in the *property* the check provides — randomness, or binding, or integrity — not the existence of the check.

We catch these in audit by:

  1. Inspecting the auth URL for the state parameter (Pattern 1).
  2. Generating multiple flows from the same session and checking for variance and entropy (Pattern 2).
  3. Reading the callback handler code and looking at where the state is stored and how it's verified (Pattern 3).

What to do

If you handle OAuth, audit your own flow against these three. Most modern OAuth libraries get it right by default — Auth.js, Passport, Authlib, etc. — but custom implementations and edge-provider integrations are where bugs live.

If you want us to look, the Security Audit + Fix SKU is $4,995 and includes manual review of auth flows. We'll find the bugs and ship the fix PRs in 7 days.

HT

The Hayaiti team

Hayaiti

Hayaiti is a productized engineering studio. We ship web, software, iOS, and cybersecurity work on fixed prices and calendar-day timelines. The team takes turns on the shipping log.

More from the shipping log

Security
Security4 min read

PCI SAQ A vs SAQ D: a 10x audit cost decision

The same e-commerce business can fall into two different PCI scopes depending on a single architectural choice. The cost difference is roughly 10x. Most teams don't realize it until the QSA quotes them.

The Hayaiti teamMay 4, 2026

Want help shipping this?

We turn posts like this into production code. Fixed price. Calendar-day timelines. Source code in your repo on day one.