Skip to main content

The Seven Wonders of OAuth Exploitation

· 14 min read
Bandana Kaur
APISec Research Labs

Sign in with Google, the signature verifies, and the token is valid. PKCE checks out, the redirect_uri matched exactly, state came back untouched. Textbook flow, start to finish. Then the application runs find_user_by_email(claims.email), and the session goes to whoever controls that address.

Ten of the OAuth and OIDC vulnerability reports we read end that way. In none of them did the protocol fail. It ran perfectly and handed over the account anyway.

That kept happening. So across every publicly disclosed OAuth and OIDC report on HackerOne from 2019 to mid-2026, read by hand, we stopped asking which control was defective and asked something else of each one: what was the attacker's pivotal move? Not which parameter was malformed. What they actually did to turn a defect into someone else's account.

Out of 105 publicly disclosed OAuth and OIDC reports, 29, the single largest group are SSRF, ReDoS, brute force, cleartext transport and information disclosure, sitting on an endpoint that happens to have /oauth/ in its path. Real bugs, several of them severe, but they're just not OAuth bugs. The same way a bug in your billing service could get filed under "billing" because of where it lives, not what it is.

Clear those out and 76 reports remain where the exploit genuinely turns on how grants and tokens were handled. Seven patterns cover the lot.

MechanismnThe attacker's pivotal move
grant_interception28Move the code or token to an attacker-controlled destination in transit
surface_injection13Execute script or inject control characters into the auth origin
identity_binding_abuse10Abuse how the app maps an IdP identity onto a local account
credential_replay9Present a credential outside its intended binding or lifetime
consent_forgery6Trick the victim's browser into completing an authorization it never intended
flow_step_bypass6Reach a path that skips a mandatory auth step
authorization_enforcement_gap4A valid token reaches a resource the server should have refused

Roughly half of what remains is what you already test for. The other half lives in code your team wrote.

The familiar half: redirects and injection​

Grant interception, 28 reports: The attacker gets the authorization response to land somewhere they control, and the victim never has to knowingly do anything. This is the classic, it's the largest family, and it is overwhelmingly a redirect_uri validation story which turns out not to be one instruction but an open-ended parsing problem.

The 14 redirect-validation bugs here broke in at least five distinct ways. Path traversal in the redirect target[1]. An IDN homograph that looked like an allowlisted domain and wasn't[2]. Plain wildcard-scoped open redirect in the OAuth flow, four separate times, at four separate companies. An access token smuggled out through the Referer header via a chain of open redirects[3]. And the one worth pinning above your desk: an open redirect chained inside an already-allowlisted host. Uber trusted any redirect_uri matching https://auth.uber.com/login?*; an attacker threaded that through login.uber.com/logout to land a victim's authorization code on their own endpoint[4].

Every one of those passed a validation check written by somebody who sincerely believed they were validating a redirect_uri. The attacker only has to find the one edge case you didn't think of; you have to think of all of them. That's why exact string matching is the recommendation and not "check it carefully."

The rest of the family is mobile: deep links registered by a malicious app to capture an authorization code mid-flow[5], and an Android deep-link handler that opened arbitrary URLs in an in-app browser with access to the app's private files[6]. There is also exactly one PKCE bug in the entire set, a code verifier generated with a weak PRNG, guessable by any app on the device[7].

Surface injection, 13 reports: Get script or protocol-control characters executing in the authentication origin and every control downstream is moot. Stored XSS on login.uber.com via an unsanitised Privacy Policy URL[8]. HTML injected through the state parameter of World ID's OIDC flow with form_post response mode, rated critical, enough to lift access tokens with minimal user interaction[9]. CRLF injection via %0d%0a in Bugzilla's redirect_uri, forging arbitrary response headers including Set-Cookie[10].

Worth noticing: state shows up here as an injection sink, not as the CSRF token everyone thinks of it as. It's attacker-supplied data your auth origin reflects.

The half hiding in your glue code​

Identity binding, 10 reports: The opening scene, ten times over. The handshake is clean and the account still moves.

Shopify had two: an email verification bypass, combined with SSO logic that matched accounts purely by email address, was enough to take over a merchant account[11][12]. Reddit had a version of the same idea: sign in with Google, then register locally with the same address, and inherit the existing account[13]. Automattic's was almost self-documenting: invite an arbitrary email to a site, verify it, and if the target had Jetpack SSO with "match accounts using email addresses" enabled, the account was yours[14]. GitLab's triage note names the whole class in a line: bypassing their verification means the third-party site assumes your address is validated, and third parties routinely use email as the primary identifier[15].

Credential replay, 9 reports. A credential presented outside its intended binding or lifetime, and the server takes it anyway. Picsart accepted a Facebook or Google access token without checking it had been minted for Picsart's own client_id, so any other app the user had authorised could impersonate them[17]. Taskcluster's token-exchange handler neither consumed authorization codes nor enforced their expiry, so a single leaked code could be replayed indefinitely to mint fresh access tokens[18]. libcurl reused OAUTH2-authenticated connections without confirming the new transfer carried the same bearer, so one user's connection could serve another's request[19].

Three different systems share one assumption, that a credential which validates is a credential that belongs here. Audience, binding and lifetime are separate checks, and in each case the one that mattered didn't happen.

Consent forgery, flow bypass, enforcement gaps: 16 reports. The last three families are variations on the authorization step being reachable in a state it shouldn't be.

On 37signals' authorization endpoint, appending .json or .xml to the request path skipped the CSRF check entirely, letting a malicious third-party app silently obtain full API access to a victim's account[20]: a routing layer with opinions about file extensions that its CSRF middleware didn't share. Shopify's Stocky app granted access at the start of the OAuth process rather than the end, which is a full authentication bypass in one clause[21]. A GitLab report used the resource-owner-password-credentials grant to skip login email verification altogether, because ROPC simply has no step where that check lives[22]. And Nextcloud enforced group-based login restrictions in the browser OIDC flow but not in bearer token validation[23]: same policy, two code paths, one of them enforcing it.

Why your spec checklist won't catch most of this​

We also coded all 76 against RFC 9700, the IETF's January 2025 consolidated OAuth security guidance[24], to see how the two views compare.

Mechanism families ranked by count against the RFC 9700 class each report maps to, if any

Rows are the seven in-scope exploit-mechanism families ranked by count; columns are the five RFC 9700 classes used, including unmapped. 52 of 76 in-scope reports are unmapped. Three of the seven families have no report in any mapped class. A further 29 out-of-scope reports sit below a divider.

That's the column axis in the figure, and it's lopsided: 14 reports map to redirect URI validation, 5 to CSRF/state, 4 to Referer leakage, 1 forced into PKCE downgrade, and 52, roughly two thirds, map to nothing at all.

Two things about that, because it's easy to over-read.

It isn't a hole in the spec. RFC 9700 has plenty to say about the unmapped pile. What it is, is a shape mismatch. RFC 9700 is organised as a set of controls: what you should have implemented, and which named attack each control defeats. That's the right shape at design review. It is not the shape of a triage vocabulary, because most of these disclosures chain two or three bugs together and a control-based lens only ever labels the first link. The part of the RFC closest to what triage actually needs is the updated attacker model, which sorts adversaries by capability: who can read the authorization response, who can read the request, who can acquire a token. It's the section nobody quotes.

What you can take home from this article​

  • Audit the account-linking code, not just the OAuth library. If it trusts an email claim without checking email_verified and pinning the identity to the issuing IdP, that's an account takeover waiting to be found. Ten of these bugs lived there.

  • Test your token endpoint for replay, audience and expiry explicitly. Exchange a code twice. Exchange one eleven minutes after issuance. Send a token minted for a different client_id. Three curl commands cover the entire credential_replay family.

  • Treat redirect_uri validation as a parsing problem, not a lookup. Exact string match, full URI, registered per client. Test traversal, unicode homographs, CRLF, then check whether any host on your allowlist has its own open redirect living inside it.

  • Diff your auth paths against each other. Browser flow versus bearer token, web versus mobile, .json versus no suffix. Several of these bugs are one code path enforcing a rule its sibling doesn't.

  • When you triage, ask what the attacker did before asking which section this is. The mechanism question left none of these reports without a home. The spec-conformance question left most of them.

Reality check: what this data can't tell you​

This is the shape of what got disclosed, on one platform, by programs mature enough to run a bounty and comfortable enough to publish the result. Severe issues go missing from public bounty data non-randomly; the worst ones are often the quietest.

Three specifics worth knowing before you cite these numbers. Three programs make up a third of the corpus: Nextcloud 17 reports, Rockstar 11, curl 7, so disclosure culture shapes this data at least as much as attacker behaviour does. Related: the four Referer-leakage reports are all Rockstar, and all four are pieces of a single researcher's attack chain disclosed on the same day. That's one finding counted four times, not a base rate. And about a dozen records have a bare advisory link as their entire body text; those were coded from the linked advisory rather than the report itself.

A zero count is also a statement about what gets paid out and published, not about what exists. No mix-up attacks appear here, and only one PKCE bug. That mostly means these rarely surface as a standalone reportable finding: a missing PKCE implementation is usually the precondition some other, more visible bug quietly depended on. Implement PKCE regardless (please.)

What the data does support is, among bugs severe enough to report and safe enough to publish, most were integration defects. Bugs in code your team wrote, in the box every OAuth diagram draws as a plain rectangle labelled "your app."

Want to catch some of these patterns? APIsec automates the ones attackers try first and most pipelines never check for. We're expanding the suite fast, guided by our research.

References​

[1] HackerOne Report #1861974, path traversal in redirect_uri validation. [Online]. Available: https://hackerone.com/reports/1861974
[2] HackerOne Report #861940, SEMrush, IDN homograph bypass of redirect_uri validation. [Online]. Available: https://hackerone.com/reports/861940
[3] HackerOne Report #835437, PlayStation, access token smuggled via Referer through an open-redirect chain. [Online]. Available: https://hackerone.com/reports/835437
[4] HackerOne Report #202781, Uber, open redirect chained through an allowlisted host. [Online]. Available: https://hackerone.com/reports/202781
[5] HackerOne Report #1700734, Shopify, Outlook authorization code retrieved by a malicious app via deep links. [Online]. Available: https://hackerone.com/reports/1700734
[6] HackerOne Report #1122177, Reddit, deep-link handler exposing session tokens to third-party apps. [Online]. Available: https://hackerone.com/reports/1122177
[7] HackerOne Report #824931, Grammarly, PKCE code verifier generated with a weak PRNG. [Online]. Available: https://hackerone.com/reports/824931
[8] HackerOne Report #392106, Uber, stored XSS on login.uber.com via an unsanitised Privacy Policy URL. [Online]. Available: https://hackerone.com/reports/392106
[9] HackerOne Report #2515808, World ID, HTML injection via the OIDC state parameter. [Online]. Available: https://hackerone.com/reports/2515808
[10] HackerOne Report #2147132, Mozilla/Bugzilla, CRLF injection via redirect_uri. [Online]. Available: https://hackerone.com/reports/2147132
[11] HackerOne Report #791775, Shopify, email verification bypass enabling SSO account takeover. [Online]. Available: https://hackerone.com/reports/791775
[12] HackerOne Report #796956, Shopify, related SSO account-matching issue. [Online]. Available: https://hackerone.com/reports/796956
[13] HackerOne Report #1815463, Reddit, local registration inheriting an SSO-linked account. [Online]. Available: https://hackerone.com/reports/1815463
[14] HackerOne Report #2037902, Automattic, arbitrary email invite and verification against Jetpack SSO email matching. [Online]. Available: https://hackerone.com/reports/2037902
[15] HackerOne Report #922456, GitLab, OAuth flow reachable with an unvalidated email. [Online]. Available: https://hackerone.com/reports/922456
[16] N. Sakimura, J. Bradley, M. Jones, B. de Medeiros, and C. Mortimore, "OpenID Connect Core 1.0." [Online]. Available: https://openid.net/specs/openid-connect-core-1_0.html
[17] HackerOne Report #739321, Picsart, access token accepted without validating the issuing client_id. [Online]. Available: https://hackerone.com/reports/739321
[18] HackerOne Report #3734676, Taskcluster, authorization code replay in the token-exchange handler. [Online]. Available: https://hackerone.com/reports/3734676
[19] HackerOne Report #1552110, curl, OAUTH2 bearer bypass on connection re-use. [Online]. Available: https://hackerone.com/reports/1552110
[20] HackerOne Report #850022, 37signals/Basecamp, CSRF check bypass via .json/.xml path suffix. [Online]. Available: https://hackerone.com/reports/850022
[21] HackerOne Report #740989, Shopify, Stocky granting access at the start of the OAuth process. [Online]. Available: https://hackerone.com/reports/740989
[22] HackerOne Report #2676025, GitLab, login email verification bypass via the ROPC grant. [Online]. Available: https://hackerone.com/reports/2676025
[23] HackerOne Report #3572848, Nextcloud, group login restrictions unenforced on the bearer token path. [Online]. Available: https://hackerone.com/reports/3572848
[24] T. Lodderstedt, J. Bradley, A. Labunets, and D. Fett, "Best Current Practice for OAuth 2.0 Security," RFC 9700, BCP 240, IETF, January 2025. [Online]. Available: https://www.rfc-editor.org/rfc/rfc9700
[25] D. Hardt, Ed., "The OAuth 2.0 Authorization Framework," RFC 6749, IETF, October 2012. [Online]. Available: https://www.rfc-editor.org/rfc/rfc6749