2FA bypass testing for your own login flow
To test your own login flow for a 2FA bypass, check that a password-only session cannot reach the account, that wrong codes hit an attempt limit, and that a code works only for the session it was issued to. We built a local two-step login (password, then a 4-digit code) that fails all three checks. A password-only session reached the account page, a scripted loop found a code in 5,599 tries, and one user's valid code completed a different user's login. Four server-side checks in fixed mode closed all three.
This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.
What 2FA bypass means
Two-factor authentication (2FA), also called multi-factor authentication (MFA) or two-step verification, asks for a second proof of identity after a password: a code sent by SMS or email, a code from an authenticator app, or a hardware key. A 2FA bypass, sometimes called MFA bypass or OTP bypass (one-time password bypass), is any way to reach a logged-in state without passing the second check. The OWASP Multifactor Authentication Cheat Sheet says one-time codes should have a short lifetime, be single use, have strict attempt limits and be invalidated after successful verification. This fixture tests the attempt limit and single use. It also tests two flaws outside that list: skipping the code step, and a code that is not bound to its session.
Two-factor authentication bypass testing sends real requests to your own login flow and checks each of these points in the responses.
Scope: a password, then a 4-digit code
Our fixture models a common pattern: POST /login with a username and password starts a pending session, then POST /verify-code with a short numeric code completes it, then GET /account requires that completed session. The policy is: nobody reaches /account until both steps pass, a wrong code has a limited number of guesses, and a code issued for one session authenticates only that session.
- Target: two short-lived HTTP servers bound to IPv4 loopback,
127.0.0.1. The runner selects free ports. - Code length: 4 synthetic digits (0000 to 9999), not the 6 digits common in production. A smaller space lets the brute-force loop finish in under a second. A 6-digit space is 100 times larger, which slows a sequential guess but does not replace an attempt limit.
- Identities: three synthetic demo accounts,
alice,bobandcharlie, with fixed test passwords. They carry no value outside this fixture. - Data: sessions and codes held in memory. There are no SMS or email providers, no customer accounts, no database writes.
A test-only GET /test-inbox endpoint lets the holder of a session read the code "sent" to it, standing in for a tester checking their own phone or inbox. The guessing loop never calls it, and no attack step in either mode uses it. For a staging engagement, agree the accounts, channels and stopping conditions with the system owner first. The Excession Labs Acceptable Use Policy describes authorized use. This article gives no permission to test another system.
Run the downloadable fixture
Download fixture.py and verify.py into the same directory. You need Python 3.9 or later and curl; the fixture uses only the standard library. Inspect the source, then run:
python3 verify.py --output rerun
The output directory must be new. The runner starts vulnerable and fixed servers in turn, runs the same scripted sequence of login, code and account requests against each, then runs a sequential code-guessing loop, saves every command and response, and stops the servers. The discrete requests go through real curl subprocesses. The guessing loop uses Python's http.client over one kept-alive connection, because a new curl process for each of up to 10,000 guesses would be slow. The evidence README says this too.
Our recorded command was python3 verify.py --output recorded-run. It exited successfully with {"cases": 10, "passed": true, "failures": []} plus the evidence path. That means every check, including the intentionally vulnerable ones, matched its expected outcome.
The run on 10 November 2026 used fixture and runner version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. Exact runtime strings, UTC timestamps, commands and source SHA-256 hashes are in results.json. A rerun will use different ports, timestamps and codes.
Observed results
Each row below is a real response from the recorded run. "Account before code" sends only the password-step session to /account. "Own code" and "account after code" are positive controls: a user who completes both steps normally. "Replay" resubmits an already-used, already-correct code. "Cross-session code" sends Bob's pending session together with Alice's valid, unused code.
| Request | Policy expects | Vulnerable mode | Fixed mode |
|---|---|---|---|
| Account before code | 403 | 200: account disclosed | 403: mfa_required |
| Own code (positive control) | 200 | 200 | 200 |
| Account after code (positive control) | 200 | 200 | 200 |
| Replay same code | 409 | 200: code reused | 409: code_already_used |
| Cross-session code | 401 | 200: wrong login completed | 401: invalid_code |
The guessing loop sent codes in ascending order from 0000 to Charlie's pending session (sess-5-charlie), which no other request used:
| Mode | Attempts | Elapsed time | Outcome |
|---|---|---|---|
| Vulnerable | 5,599 | 0.524 s | 200: code found, login completed |
| Fixed | 5 | 0.001 s | 423: locked |
One caveat on the fixed row: the fixture never issues codes 0000 to 0004, so an ascending loop is sure to hit the lockout before it finds the code. The fixture source says so. The positive controls are important too. A server that blocked every request would also stop the loop, and it would stop real users. Both own-code checks returned 200 in both modes, so the fixes did not break the normal sign-in.
Verify a finding before you report it
A result worth reporting names the exact request, the session and identity involved, the expected response under policy, and the observed response with its body. In the recorded vulnerable run, the cross-session request looked like this:
curl --noproxy '*' --silent --show-error --include \
--http1.1 --connect-timeout 1 --max-time 3 --request POST \
--header 'Content-Type: application/json' \
--data '{"session": "sess-3-bob", "code": "6225"}' \
http://127.0.0.1:56059/verify-code
The recorded response was HTTP/1.1 200 OK with body {"status": "authenticated"}. The server issued code 6225 for Alice's separate pending login (sess-4-alice), and it accepted that code for Bob's session sess-3-bob. Record the session, the code and the response together. A 200 status alone does not prove that the code was unbound.
For the attempt-limit finding, report the count of attempts. The vulnerable server accepted attempt 5,599, and the fixed server locked the test session after 5. Both numbers come from vulnerable-brute-force-summary.json and fixed-brute-force-summary.json.
Fix and retest
The same fixture source carries a vulnerable and a fixed mode behind one flag. Fixed mode turns on four checks that vulnerable mode skips:
if self.server.mode == "fixed" and session["state"] != "authenticated":
return self.reply(403, {"error": "mfa_required"})
That is the pending-state check on /account: a session stays pending_mfa, not logged in, until the code step passes. The second check adds an attempt cap with lockout to /verify-code:
if session["locked"]:
return self.reply(423, {"error": "locked", "attempts": session["attempts"]})
if session["used"]:
return self.reply(409, {"error": "code_already_used"})
if code != session["code"]:
session["attempts"] += 1
if session["attempts"] >= MAX_ATTEMPTS:
session["locked"] = True
return self.reply(423, {"error": "locked", "attempts": session["attempts"]})
return self.reply(401, {"error": "invalid_code", "attempts_remaining": MAX_ATTEMPTS - session["attempts"]})
The third check compares the code with the value stored for that session. This binds the code to the session and the user who requested it:
if code != session["code"]:
...
The vulnerable handler compares the code with store.last_issued_code, the most recent code issued to any session. Alice's code was the latest one, so it completed Bob's login. The fourth marks a code used after one successful verification, so replaying it fails even with the right value and the right session:
session["state"] = "authenticated"
session["used"] = True
The fixture already contains both code paths, and a flag selects one. A real system also needs rules for how a locked-out user recovers and how long a lockout lasts. Anyone who knows a pending session ID can also lock it, so real systems often pair the lockout with a fresh login. The OWASP cheat sheet covers recovery. After enabling all four checks, the runner repeated every request: the account check returned 403 before the code step and 200 after it, the replay returned 409, the cross-session code returned 401, and the guessing loop locked out after 5 attempts instead of 5,599.
Evidence bundle
- Machine-readable results: every request, identity, expected and observed outcome, and the brute-force summaries, with response file names and hashes.
- Exact recorded curl commands for the discrete requests; the ports refer to the finished run.
- SHA-256 manifest: compare downloaded sources and evidence. This is an integrity check, not independent attestation.
- Fixture instructions and limitations, server source and verification source.
All accounts, passwords and codes are synthetic demo data. The bundle includes the actual HTTP response text. No production credentials or customer data were involved.
What this experiment does not cover
This fixture used a 4-digit code and three accounts against one toy login flow, proven with ten scripted requests and one guessing loop. It did not test SMS interception, authenticator-app time windows, push-notification fatigue, recovery codes, remember-this-device cookies, social-engineering of a support desk, or a real 6-digit production code space, where a sequential attack takes longer but unlimited attempts are still a flaw. The OWASP WSTG test for multi-factor authentication (WSTG-ATHN-11) lists this broader set of checks, including how the application behaves when the second factor is unenrolled or unavailable, which this fixture does not model. A fixed toy server says nothing about the security of a production login. Run the same test matrix against your real flow, with its real code delivery, session store and recovery paths.
Related reproductions on this site: session fixation covers a session ID that survives login instead of a code that survives its session, API rate limiting covers the attempt-cap problem on a different endpoint, and broken access control covers a resource reached without the right state. The authorization pattern behind the step-skipping flaw here is the same one in our API authorization testing post: a server that checks that a session exists, but not what state it is in. Excession Labs runs this kind of authentication testing in its security assessments, with separate accounts to prove each cross-account effect. See how a finding like this looks in a sample report.