The Magic Link That Showed Itself to the Wrong Person
A feature meant to email a student's login link put it on the teacher's screen instead — turning convenience into one-click account takeover.
Target anonymized · an education platform's classroom product
I spend a lot of time in the boring corners of applications — the admin panels, the roster screens, the little "helper" buttons that exist so a busy adult doesn't have to type an email address twice. This one was an education platform's classroom product: teachers spin up a class, students join, and the teacher gets a tidy dashboard to manage everyone. Classic B2B2C surface. Lots of roles, lots of account types, lots of trust boundaries drawn in pencil.
The thing that pulled me in was a phrase I've learned to love: "Send login link." Anytime an app can generate a credential on behalf of another human, I want to know exactly who ends up holding it.
The spark
Inside the roster, each student had a little action menu. Under Send login → Get link, I expected the usual: a click, a spinner, and a green toast that says "A login link has been emailed to the student." That's the safe pattern. The credential goes to the person it authenticates, over a channel only they control.
Instead, a modal popped open and showed me — the teacher — the actual, full magic URL. Not a confirmation. The link itself. Sitting there, selectable, copyable, mine.
That's the moment the hair on my neck went up. A magic link is a one-shot password. The UI had just printed someone else's password on my screen and called it a feature.
Digging in
My first instinct was to talk myself out of it, because a good finding survives your own skepticism. Lots of classroom products create managed child accounts — limited sub-profiles that live entirely under the teacher's control. For those, showing the login link isn't crazy; the teacher provisioned the kid, owns the relationship, and there's no separate person to email. So I assumed I was looking at a managed account and this was working as designed. Dead end. Move on, right?
Except the platform also let people join a classroom with an existing personal account through a public invite link. Same roster. Same little action menu. Same Get link button.
So I ran the what-if. I made a second, fully independent personal account — its own email, its own password, its own progress history, nothing to do with my teacher persona. I had it join my classroom through the public invite. Then I went back to the roster, opened the menu on that user, and clicked Get link.
The modal showed me a valid magic URL for a stranger's real account.
A magic link is a bearer credential. The instant your interface displays it to anyone other than the person it authenticates, it stops being authentication and becomes a skeleton key.
The exploit
No fancy payload. No race condition. The vulnerability was that the server happily returned the login token in a response the teacher was authorized to see, for a victim the teacher had no business authenticating as.
The flow, defanged:
POST /api/classrooms/CLASS_ID/students/VICTIM_ID/login-link HTTP/2
Host: api.example.com
Cookie: session=<attacker_teacher_session>
HTTP/2 200 OK
Content-Type: application/json
{
"status": "ok",
"loginUrl": "https://app.example.com/magic?token=<one_time_login_token>"
}
The intended response was closer to { "status": "sent", "channel": "email" } — an acknowledgement, not the secret. Instead the secret rode back in the body.
From there it's three steps:
1. Attacker (teacher) copies loginUrl from the modal.
2. Attacker opens it in a fresh incognito window.
3. app.example.com validates the token and mints a session
→ attacker is now logged in AS the victim, full access.
The public invite link is what turns this from a curiosity into a weapon. I don't need to guess victims or already control them — I publish a friendly "join my class" link, wait for anyone with a real account to click it, and every one of them becomes a Get link away from full takeover.
Impact
Complete, silent account takeover of any real user who joins the classroom. Full access to their profile, their history, whatever personal data and settings live behind their login — with no interaction from the victim beyond joining, and no notification that anything happened. Because these are minors' learning accounts in many cases, the stakes climb from "bad" to "regulatory incident."
The fix
The root cause is a delivery mistake dressed up as a UX convenience. The remedies, in order of importance:
- Never render a magic link client-side. Deliver it only to the account's verified email, out of band. The UI should return an acknowledgement, never the token.
- Bind the feature to account type. If a managed child account genuinely has no email, handle that case explicitly — and refuse to generate a viewable link for any full personal account.
- Re-check authorization on the credential, not the roster. Being able to see a user in your class is not consent to authenticate as them.
- Scope and expire aggressively. One-time use, short TTL, and invalidate on view — so even a leaked link has the smallest possible window.
The lesson I keep re-learning: the most dangerous bugs aren't exotic. They're a secret that took one wrong turn on its way out the door.
All identifiers, targets and payloads in this post are anonymized or defanged. Findings were reported and resolved through responsible disclosure.