Attacking 2FA in Modern Web Applications

Attacking 2FA in Modern Web Applications

Modern web applications have evolved well beyond static HTML forms. With client-heavy SPAs, complex authentication flows, and increasingly mature security controls, today’s testers face different challenges than a decade ago. One of the most prevalent security upgrades across platforms is the adoption of two-factor authentication (2FA). While this adds an extra hurdle for attackers, misconfigurations and flawed implementations still leave many systems vulnerable.

In this post, we’ll break down common pitfalls in 2FA implementations, how to test for them, and where things usually go wrong. This isn’t a general overview of 2FA — this is for pentesters looking to break it.

Session Timing: Before or After 2FA?

One of the most common architectural choices you’ll see is whether a session starts before or after the 2FA step. In case application starts authentication session right after confirming the username and password combination they must carefully protect all routes and preserve state that 2FA is not yet completed. This opens the gate for misconfiguration or missing to guard certain API endpoint.

For example:

POST /login HTTP/1.1
Host: target.com
Content-Type: application/json
{
"username": "victim@domain.com",
"password": "password123"
}
HTTP/1.1 200 OK
Set-Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6...; Path=/; HttpOnly
{
"2fa_required": true,
"message": "Please complete second factor"
}

Notice the session is issued immediately after the password check, but before 2FA is verified. This opens the door for several issues if routes aren’t properly guarded. This is the right scenario to enumerate all known routes with that session and see if some of them are not checking the state of 2FA.

Unprotected Routes After Initial Login

A classic misstep is failing to restrict sensitive endpoints before the 2FA step is complete. For example, if /account/settings or even worse, an action route like /transactions/initiate, is accessible right after step one, you’ve effectively bypassed the purpose of 2FA.

Manually test this by replaying a request with the session cookie obtained pre-2FA:

GET /account/settings HTTP/1.1
Host: target.com
Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6...

If you get a 200 OK and access to sensitive data, that’s a problem. Proper implementations should gate access using middleware or backend checks that validate that 2FA has been completed.

Prematurely Exposed Props

Another variation of this mistake is leaking sensitive data in the 2FA prompt response. Modern SPAs often require data from the backend, and to simplify development, shared properties—such as the user object—are included in all responses. This makes sense, as the frontend typically needs access to the current user’s information on every page, for example, to display their name in the navigation bar.

However, too much convenience and reliance on one-size-fits-all solutions can compromise security. For instance, if we want to display pending transactions in the navigation bar and make this component persistent across all pages (including the 2FA code submission page), we risk prematurely exposing sensitive properties. For example:

HTTP/1.1 200 OK
{
"2fa_required": true,
"user_data": {
"email": "victim@domain.com",
"role": "admin",
"pending_transactions": [...]
}
}

This tells an attacker more than they need to know. If you can brute-force usernames, you can start harvesting metadata — roles, account status, maybe even token balances — before 2FA is solved.

2FA Code Bruteforce & Race Conditions

Nowadays majority of 2FA codes are 6 digits and due to relatively small number of combinations they are very vulnerable to bruteforce. Many systems implement custom 2FA where the server verifies the code like this:

POST /2fa/verify HTTP/1.1
Host: target.com
Content-Type: application/json
Cookie: session=...
{
"code": "123456"
}

If there’s no rate limiting or lockout, you can brute-force the code, especially for time-based ones with predictable formats (e.g., 6 digits, 30-second rotation). Test by submitting codes rapidly in sequence and monitor responses:

  • Does the response time change?
  • Does it block after N attempts?
  • Does it validate codes that shouldn’t yet be valid?

Brute-forcing can also work via race conditions. If the server doesn’t properly handle concurrency, sending multiple valid codes in parallel may result in one of them succeeding even if others are expired or invalid.

Missing Rate Limit at Issuing Codes

For systems that send SMS or email codes, another weak point lies in the issuance process itself. If an attacker can trigger the generation of millions of 2FA codes—and the system invalidates them based only on elapsed time—it becomes much easier to brute-force a valid code.

Even with rate limiting in place, the sheer volume of issued codes could reduce the number of required attempts to just a few, making a successful guess significantly more likely.

POST /2fa/send HTTP/1.1
Host: target.com
Cookie: session=...
{
"method": "sms"
}

Try sending this repeatedly.

Recovery Codes Not Rotated After Use

Applications often provide backup codes in case you lose access to your device. Recovery codes are typically designed to act like one-time passwords. If a recovery code is ever exposed — through a compromised email, a screenshot, or a physical note — it becomes a vulnerability. 2FA is meant to significantly raise the security bar. A static, reusable recovery code introduces a static weak point in that system.

But if a used code isn’t invalidated/rotated, you can reuse it:

POST /2fa/verify HTTP/1.1
Host: target.com
Cookie: session=...
{
"recovery_code": "ABCD-1234-EFGH"
}

Prompt Bombing (Push Notification)

Apps that rely on push-based 2FA (e.g., approve/deny prompts) can be abused by repeatedly triggering login attempts. This leads to “prompt fatigue,” where users might approve a request by habit.

Simulate this attack by replaying login attempts and triggering multiple push notifications within a short window. No need to compromise the device — user psychology can work in your favor. Same as SMS/email delivery there should be a proper rate limit.

2FA Code Leaks in HTTP Responses

Sometimes devs forget to clean up debug data or temporary test responses. I’ve seen 2FA codes returned in error objects, stack traces, or even embedded in HTML like. This vulnerability is only possible where the server side creates a 2FA code and then sends code via SMS or email. Try searching for delivered code in the HTTP history and see if it leak before you ever sent that code to the server.

Scrape 2FA-related responses for these accidental leaks. Especially common in dev/test environments.

Weak Recovery Methods

Security questions like “What’s your favorite color?” are not 2FA, or even worse when they are used for recovering lost 2FA device. If the fallback method is trivial or publicly guessable, it nullifies the entire second factor.

Date

17. May, 2025

Tags

Security Testing, Penetration Testing

There are no related articles...

Let's get you started

Create your account with PentestPad now, a tool developed by pentesters for pentesters.

logo-cta