June 9, 2026
Three Bugs, One Takeover: How a Username Hijacked Every Account
None of them looked critical on their own. Chained together, they handed over the entire platform — without the victim clicking a thing.
Muhammad Uzair
5 min read
When you commission a security audit, you brace yourself for the one catastrophic finding: the SQL injection, the exposed admin panel, the hardcoded key in a public bundle. But the result that actually keeps you up at night is rarely a single bug. More often it's two or three medium-looking ones that quietly snap together like puzzle pieces.
That's exactly what we found while auditing the backend of our EdTech platform — a SaaS where teachers run courses, hand out assignments, and review student submissions. The top three findings each looked manageable in isolation. Chained, they formed a complete, hands-off account takeover: any teacher's session could be stolen, an admin's session along with it, and the victim never had to click anything at all.
Here's how the three pieces fit — and what we changed so they never fit again.
Piece #1 — A profile name that runs code (Stored XSS)
The first bug lived somewhere nobody thinks twice about: the profile name field.
Our PUT /api/profiles/{id} endpoint accepted any string for name and schoolName and stored it exactly as sent — no sanitization, no encoding. So instead of "Ms. Khan," a user could save this as their name:
<img src=x onerror="/* attacker JavaScript here */"><img src=x onerror="/* attacker JavaScript here */">To the database, it's just a string. The problem appears at render time. When the frontend displays that name, the browser doesn't see "a teacher whose name contains some characters." It parses an <img> tag with a broken source and an onerror handler — and dutifully runs the JavaScript inside it. In the logged-in victim's session. With their permissions, their cookies, their browser storage.
This is stored (persistent) cross-site scripting, and it's the nastiest XSS variant precisely because there's no phishing link to click. The payload lives in the database and gets replayed to victims automatically. The browser genuinely cannot tell injected code from legitimate data — to it, it's all just characters in the HTML.
On its own, you might rate this "bad, but contained." Hold that thought.
Piece #2 — The keys left on the kitchen table (tokens in localStorage)
The second bug was about where we kept our credentials.
We used JWTs for auth — a short-lived access token plus a 7-day refresh token. Whoever holds those tokens is authenticated, so the single most important decision about them is: who can read them?
We were storing both in localStorage. And localStorage has one defining property that matters here: any JavaScript running on the page can read it in a single line.
localStorage.getItem('refreshToken')localStorage.getItem('refreshToken')Compare that to an httpOnly cookie. The browser still attaches it to every request automatically — but JavaScript literally cannot read it. document.cookie won't even show it. That one property is the entire difference between "an injected script can steal this" and "it can't."
The refresh token made it worse. It was valid for seven days, it was returned in the login response body, and it survived logout. So a single read wasn't a momentary leak — it was a week-long master key that mints fresh access tokens on demand.
Again, in isolation: a known anti-pattern, worth fixing, not obviously a five-alarm fire. But now look at Pieces #1 and #2 side by side. One bug lets an attacker run JavaScript in a victim's browser. The other leaves the credentials sitting in a spot that JavaScript can read. The gun and the ammunition.
We were still missing one thing: a way to point the gun at everyone.
Piece #3 — The endpoint that introduced everyone (BOLA)
The third bug was an authorization gap, and it's the one that turned a single payload into a platform-wide weapon.
GET /api/teachers returned a paginated list of every registered teacher — all of them — to any authenticated teacher. Names, emails, schools, subscription status, even isAdmin flags. The endpoint checked "are you logged in?" but never "are you allowed to see this?"
That's Broken Object Level Authorization (BOLA) — specifically the list-level flavor, where a collection that should have been scoped or admin-only gets handed out wholesale. By itself it's a privacy problem: any free-tier user could harvest the whole platform's contact list for phishing, and read off exactly which accounts were admins.
But here's why it was the keystone of the chain. That teacher list returns every teacher's name field. And remember what could live in a name field?
Putting it together: one payload, every session
Stand the three pieces up in a row and the attack writes itself. No clicks required.
- Plant. An attacker self-registers as a teacher and sets their own profile
nameto a script that reads both tokens out oflocalStorageand sends them to a server they control. (Piece #1 — the field never sanitized it.) - Deliver. Any time any teacher loads a page that calls
GET /api/teachers, the attacker's poisoned name is in that response — and the script executes in that teacher's browser. (Piece #3 — the list delivers the payload to everyone, automatically.) - Steal. The script reads the victim's tokens straight out of
localStorage. (Piece #2 — they were readable.) - Persist. With the 7-day refresh token, the attacker mints new access tokens for a week, even after the victim logs out.
- Escalate. If the victim happens to be an admin — and the BOLA list conveniently told the attacker who the admins are — the stolen admin token unlocks every admin endpoint.
A username took over the platform. That's the whole story. And notice the most important property of a chain: breaking any single link collapses it. Sanitize the name field and the payload never plants. Move the refresh token to an httpOnly cookie and the script has nothing to steal. Either fix alone defuses the bomb — which is exactly why we fixed both, and the keystone too.
What we changed
The fixes map cleanly onto the three pieces, and the satisfying part is how much leverage each one has:
- Encode and sanitize user input (kills #1). Validate name fields to reject control and HTML characters, sanitize on write as defense in depth, and — most importantly — encode on output so the browser always treats a name as text, never as a tag. A tightened Content-Security-Policy
script-srcis the third net behind those two. - Get credentials out of JavaScript's reach (kills #2). Refresh token in an
httpOnly; Secure; SameSitecookie, never inlocalStorageor a response body. Access token in memory only — a variable that vanishes on refresh, so there's nothing persistent to grab. Add refresh-token rotation so a stolen token is single-use, plus an absolute session cap. - Authorize the object, not just the session (kills #3). Gate the full teacher list behind the admin role — the same guard already protecting the genuine admin routes — and serve individual users their own data through a scoped, self-only endpoint.
The lesson worth keeping
If there's one thing this chain hammered home, it's that severity is an emergent property, not a per-bug label. Scored individually, these were a high and two mediums — the kind of findings that can sit in a backlog for a quarter. Scored as a chain, they were a critical account takeover hiding in plain sight.
So when you triage your own findings, don't just ask "how bad is this bug?" Ask "what does this bug unlock when it stands next to the others?" Map the links, not just the nodes. The catastrophic exploit is rarely one spectacular hole — it's three boring ones that happen to line up.
And maybe give your profile-name field a second look while you're at it.
Found this useful? The most valuable habit you can build from it is threat-modeling in chains rather than in isolation — your worst-case scenario almost always lives in the seams between findings, not inside any one of them.