← All posts

Anatomy of an OAuth account takeover

Missing state, loose redirect URIs and unverified email linking — the OAuth and "Sign in with…" mistakes that keep turning into account takeover, mapped against RFC 9700.

"Sign in with Google/GitHub/Microsoft" is one of the highest-value features to test in any web assessment. It touches authentication, it's often bolted on late, and the protocol leaves a lot of room for implementation mistakes. Almost every OAuth account takeover I've seen comes down to one of four problems.

In January 2025 the IETF published RFC 9700, "Best Current Practice for OAuth 2.0 Security", which consolidates a decade of lessons. I'll reference it as we go.

A quick refresher: the authorization code flow

  1. Your app redirects the user to the provider: GET /authorize?client_id=…&redirect_uri=…&state=…&code_challenge=…
  2. The user logs in and consents.
  3. The provider redirects back: GET https://app.example/callback?code=XYZ&state=…
  4. Your backend exchanges code (plus the PKCE code_verifier) for tokens.
  5. Your app identifies the user from the ID token or userinfo endpoint and creates a session.

Each step has a classic failure mode.

1. Missing or unchecked state → login CSRF

state binds the callback to the browser session that started the flow. If the app doesn't generate it, or doesn't check it on return, an attacker can start a flow with their own provider account, stop before the callback, and trick the victim into visiting …/callback?code=ATTACKER_CODE.

The victim is now logged into the attacker's account — or worse, on "connect your account" pages, the attacker's identity gets linked to the victim's existing account, giving the attacker a permanent login to it.

Fix: generate a high-entropy state per flow, store it server-side or in a signed, SameSite cookie, and reject callbacks where it doesn't match. PKCE also protects against authorization code injection: RFC 9700 requires it for public clients and recommends it for confidential ones.

2. Loose redirect_uri validation → code theft

If the provider (or your own authorization server) accepts redirect URIs by prefix or pattern, an attacker can steer the code somewhere they can read it:

  • https://app.example.attacker.com/ (prefix match on the host)
  • https://app.example/callback/../open-redirect?to=https://attacker.com
  • A wildcard subdomain where one forgotten subdomain is takeover-able

Once the code lands on an attacker-controlled page — directly, via an open redirect, or through a Referer header — they can try to redeem it.

Fix: RFC 9700 requires exact string matching of redirect URIs, with a narrow exception for port numbers in localhost redirect URIs for native apps. Register every callback explicitly, and remove open redirects from your app, since they are the usual pivot.

3. Using the implicit grant

The implicit flow (response_type=token) returns the access token in the URL fragment. Tokens in URLs leak through browser history, logs and referrers, and there's no client authentication or PKCE to stop replay. RFC 9700 says clients should not use the implicit grant, and that the resource owner password credentials grant must not be used.

Fix: SPAs and mobile apps should use the authorization code flow with PKCE.

4. Trusting the wrong identity claim → pre-account takeover

This one is logic, not protocol. Common variants:

  • Linking by unverified email. The app finds an existing account with the same email as the OAuth profile and logs the user in. If the provider (or a custom OIDC IdP) lets someone set an email they haven't verified, they can claim anyone's account. Check email_verified, and better still, key accounts on the provider's stable subject identifier (iss + sub), not on email.
  • Pre-registration. An attacker registers victim@company.com with a password before the victim ever signs up. When the victim later uses "Sign in with Google", the app merges into the attacker's account — and the attacker's password still works. Require email verification before an account is usable, and invalidate other credentials when merging.
  • Accepting ID tokens without validation. Verify signature, iss, aud (must be your client ID), exp and nonce. An ID token minted for a different app on the same provider is a valid token — just not for you.

My testing checklist

  • Remove or tamper with state — does the callback still succeed?
  • Replay a code; redeem a code issued to a different client.
  • Fuzz redirect_uri: extra path segments, subdomains, @, encoded characters, open redirect chains.
  • Check whether response_type=token is accepted.
  • Link a provider account whose email matches an existing user. Is the email verified?
  • Sign up with a victim's email first, then log in via OAuth as the "victim".
  • Swap the id_token for one issued to another client.

The takeaway

OAuth is secure when implemented to the current best practice, and RFC 9700 condenses that into a short list: authorization code + PKCE, exact redirect URIs, no implicit grant, and validate everything you receive. Most takeovers I find come from the logic around the protocol — how accounts are linked — so that's where to spend review time.

References