All posts
VibeZero Team21 min read

The second factor was on. Nobody had to break it.

MFA bypass rarely breaks the second factor. It uses a second login path, an unchecked aal claim, a guessable code, or the reset flow. Five shapes, with fixes.

  • security
  • vibe coding
  • engineering

Multi-factor authentication is the one control nobody argues about. It is the first thing a security questionnaire asks for, the last thing anyone reviews, and the recommendation that closes most conversations about credential stuffing and password strength. Turn it on and a stolen password stops being enough.

That is true, and it is also why the failure mode is so consistent. “We have MFA” describes an enrollment. It does not describe an enforcement, and those are two different facts about your application that are stored in two different places. Every bypass in this post is against a system where MFA was on, configured, and working exactly as documented. Not one of them involves computing a TOTP code without the secret.

The useful mental model is a building with one guarded entrance and four unguarded ones. Nobody picks the lock on the guarded door.

Five bypasses, and not one of them breaks TOTP

The distinctions matter because each shape defeats a different control, and a team that has fixed one usually believes it has fixed all five.

  • A second login path that never had the factor attached to it.
  • A factor that is enrolled but never checked by the code that returns data.
  • A code that can be guessed, because the attempt limit is on the wrong axis.
  • A recovery flow that hands back the account without asking for the factor.
  • A session stolen after the factor was correctly presented.

Only the third is about the code itself, and even there the arithmetic is not about breaking the algorithm. The first two are authorization bugs wearing an authentication costume, which is why they survive a login review: the login is not where they live.

A second door, with no guard on it

The cleanest published example of shape one is CVE-2024-12802 in SonicWall SSL-VPN. SonicWall’s notice states that “this vulnerability allows an attacker to bypass MFA in SonicWall SSL-VPN by exploiting the separate handling of UPN and SAM account names in Microsoft Active Directory integration.” The NVD record spells out the consequence: the separate handling arose “when integrated with Microsoft Active Directory, allowing MFA to be configured independently for each login method and potentially enabling attackers to bypass MFA by exploiting the alternative account name.”

Read that twice, because the mechanism is almost too plain to feel like a vulnerability. Active Directory lets one human be spelled two ways, as a user principal name (user@domain.com) and as a SAM account name (DOMAIN\username). Those are the same person, the same password and the same entitlements. They were not the same login method, and MFA was attached to the login method. An attacker holding valid credentials, which is the ordinary outcome of a phishing run or a credential dump, typed the other spelling and walked in.

NVD scores it 9.1 critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) and classifies it as CWE-305, “authentication bypass by primary weakness,” whose definition is worth reading as the thesis of this entire post: “the authentication algorithm is sound, but the implemented mechanism can be bypassed as the result of a separate weakness that is primary to the authentication error.” The algorithm was fine. It was not the thing in the way.

The CVE was published on 2025-01-09 and the record was still being modified in June 2026, which is the part worth noticing: a bypass of this shape is not a single line to delete. Every path has to be enumerated before any of them can be called covered.

Your app almost certainly has more than one login path too, and they rarely got built on the same day:

  • Email and password, which is where you put the TOTP prompt.
  • A social or SSO button, added later because signups were slow. That one comes with its own separate failure, since the credential it returns is delivered to whatever address passed the redirect URI check.
  • A magic link, added for the people who forget passwords.
  • The password reset flow, which is a login that happens to change a credential on the way through.

Supabase makes the collision explicit rather than hiding it. Its MFA documentation defines the lower assurance level as the case where a “user’s identity was verified using a conventional login method such as email+password, magic link, one-time password, phone auth or social login.” Social login and magic links sit in the same bucket as a bare password. If your second-factor prompt is a screen in the email-and-password flow, then “Continue with Google” is the SonicWall bug with a nicer logo, and it is the door your attacker will use, because it is the door that is unlocked.

The factor is enrolled and nothing checks it

Shape two is the most common in the applications we look at, and it is the least dramatic. Nothing is broken. A user enrolled a TOTP factor, the QR code scanned, the app said MFA was enabled, and the enrollment is genuinely recorded. Then the reading and writing of data never consults it.

Supabase encodes the distinction in the token: the higher assurance level means the “user’s identity was additionally verified using at least one second factor, such as a TOTP code or One-Time Password code,” and “this assurance level is encoded in the aal claim in the JWT associated with the user.” So the fact you need is present on every request. It just has to be read by something.

The trap is that a password login against an account with an enrolled factor still returns a working session. It is an aal1 session, and aal1 is a value, not an error. If your row level security policies check auth.uid() and stop there, that session reads everything its owner can read, and the second factor the user diligently set up gated a screen rather than the data behind it. An attacker with a stuffed password never sees the screen.

Putting the check in the database is what makes it unconditional, and credential stuffing covers the restrictive policy that does it. Two things that post did not say are worth adding, because both turn a correct policy into a decorative one.

The first is scope. An aal2 requirement on billing_accounts says nothing about the other twenty tables, and it says nothing about Storage buckets or an Edge Function holding its own client. Enforcement is per object, so a helper keeps the predicate in one place instead of nineteen copies drifting apart:

-- One definition of "this session actually completed a second factor", so
-- tightening it later is one edit rather than a search across every policy.
create or replace function public.has_aal2()
returns boolean
language sql
stable
as $$
  select coalesce((select auth.jwt() ->> 'aal') = 'aal2', false);
$$;

-- Applied as restrictive so it subtracts. A permissive policy is OR-ed with
-- the others on the table, so writing this one permissively would add a way in
-- rather than close one.
create policy "second factor required"
on public.billing_accounts
as restrictive
for all
to authenticated
using (public.has_aal2())
with check (public.has_aal2());

Note for all and the with check clause. A policy written for select grades reads and leaves every insert, update and delete ungated, which on a billing table is the wrong half.

The second is the one that voids the whole exercise. Row level security does not apply to the service_role key. If a server route, an Edge Function or a webhook handler talks to Postgres with that key, it is not subject to the policy above, and any authorization it performs is whatever the handler’s own code checks. A route that reads the user id from a request body and trusts it has no second factor requirement no matter what the policy says, which is one of several reasons service role key exposure is graded the way it is. An aal2 policy protects the paths that go through PostgREST as the user. It protects nothing that goes around them.

One documented sharp edge while you are in here: Supabase notes that “unenrolling a factor will downgrade the assurance level from aal2 to aal1 only after the refresh interval has lapsed.” A session does not lose its assurance level the instant a factor is removed, so a flow that removes a factor and expects immediate downgrade needs an explicit session refresh.

Guessing the code, with the arithmetic done

Six digits is a million possibilities, which sounds like enough until you check how many of them are live at once and how many tries the server allows. Both numbers are readable.

Supabase verifies TOTP through pquerna/otp with these options, from internal/api/mfa.go in supabase/auth:

totp.ValidateCustom(params.Code, secret, time.Now().UTC(), totp.ValidateOpts{
    Period:    30,
    Skew:      1,
    Digits:    otp.DigitsSix,
    Algorithm: otp.AlgorithmSHA1,
})

Skew: 1 is the number that matters and it is there for a good reason: phone clocks drift, and people finish typing after the window turns over. A skew of one accepts the previous window, the current one and the next, so three codes are valid at any instant, not one. The chance a random six-digit guess lands is therefore 3 in 1,000,000, and the median number of guesses to a first hit (the point where the attacker’s odds pass a coin flip) is about 231,000.

Now the attempt limit. Supabase’s rate limits page allows “15 requests per hour” for “Create or Verify an MFA challenge”, covering /auth/v1/factors/:id/challenge and /auth/v1/factors/:id/verify in one budget, and it is limited by “IP Address”. Self-hosted, the same number is RateLimitChallengeAndVerify in internal/conf/configuration.go, default 15.

One request of that budget goes on creating a challenge, so the honest figure is fourteen guesses an hour rather than fifteen. It is not worse than that, because a failed verification does not consume the challenge: in VerifyFactor the challenge is destroyed when it has expired and marked verified on success, and an invalid code returns “Invalid TOTP code entered” without touching it. So one challenge, whose expiry defaults to 300 seconds, comfortably carries fourteen attempts.

Fourteen an hour from one address is a real defense. At that rate a single attacker address needs about 16,500 hours, close to two years, for that coin flip. Nobody is doing that.

The problem is the axis, and it is exactly the axis problem credential stuffing documents with Okta’s observation that attack traffic arrived “through a variety of residential proxies.” A per-IP limit divides by the number of addresses an attacker has. Fourteen guesses per address per hour means roughly 16,500 addresses buys the coin flip, and there is no deadline: each guess is an independent draw against whatever codes are live when it arrives, so the attempts can be spread over as long as the attacker likes. Sixteen thousand addresses is not an exotic capability, and it is the specific capability that commercial proxy pools sell.

The other half is that the verify path itself keeps no per-factor score. There is no counter on the factor and no lockout, so the account under attack has no memory of the previous 200,000 guesses.

Supabase’s answer is a hook, and the documentation puts this exact use case first. “You can add additional checks to the Supabase MFA implementation with hooks,” it says, and the first example listed is to “limit the number of verification attempts performed over a period of time.” The MFA verification hook receives factor_id, user_id and a valid boolean, and returns a decision of continue or reject. Critically, it is keyed on the factor and the user, which is the axis that cannot be widened by renting addresses.

The example in those docs is a two-second debounce between failures. That is the right axis and the wrong magnitude: two seconds permits 1,800 attempts per hour against one factor, which reaches the same coin flip in about five days of uninterrupted guessing. Spend a budget instead of a delay:

-- The composite primary key is what makes the upsert below atomic: two
-- concurrent guesses cannot both read "9 failures" and both write "10".
create table if not exists public.mfa_attempts (
  user_id           uuid        not null,
  factor_id         uuid        not null,
  failures          int         not null default 0,  -- resets each window
  lifetime_failures int         not null default 0,  -- only a success resets this
  window_started_at timestamptz not null default now(),
  primary key (user_id, factor_id)
);

-- Deliberately not security definer: Supabase's hook documentation recommends
-- against it, and the grants below give the hook exactly the access it needs.
create or replace function public.hook_mfa_verification_attempt(event jsonb)
returns jsonb
language plpgsql
set search_path = public
as $$
declare
  uid          uuid              := (event ->> 'user_id')::uuid;
  fid          uuid              := (event ->> 'factor_id')::uuid;
  budget       constant int      := 10;   -- per window
  hard_stop    constant int      := 100;  -- across windows, until a success
  window_len   constant interval := interval '15 minutes';
  attempt_count int;
  lifetime      int;
begin
  -- A correct code clears the ledger. Real users fat-finger a digit and should
  -- not accumulate toward a lockout for the rest of the day.
  if (event ->> 'valid')::boolean then
    delete from public.mfa_attempts where user_id = uid and factor_id = fid;
    return jsonb_build_object('decision', 'continue');
  end if;

  -- Every SET expression below reads the pre-existing row, so both branches
  -- test the same old window before either overwrites it.
  insert into public.mfa_attempts (user_id, factor_id, failures, lifetime_failures)
  values (uid, fid, 1, 1)
  on conflict (user_id, factor_id) do update
    set failures = case
          when mfa_attempts.window_started_at < now() - window_len then 1
          else mfa_attempts.failures + 1
        end,
        window_started_at = case
          when mfa_attempts.window_started_at < now() - window_len then now()
          else mfa_attempts.window_started_at
        end,
        -- Deliberately not reset by the window rollover, so an attacker who
        -- simply waits out each lockout still runs into a ceiling.
        lifetime_failures = mfa_attempts.lifetime_failures + 1
  returning failures, lifetime_failures into attempt_count, lifetime;

  if lifetime > hard_stop then
    return jsonb_build_object(
      'decision', 'reject',
      'message',  'Too many incorrect codes. Sign in again to continue.'
    );
  end if;

  if attempt_count > budget then
    return jsonb_build_object(
      'error', jsonb_build_object(
        'http_code', 429,
        'message',   'Too many incorrect codes. Try again in a few minutes.'
      )
    );
  end if;

  return jsonb_build_object('decision', 'continue');
end;
$$;

-- Without these grants the hook either cannot run or becomes callable by your
-- own users, who would then be able to clear their own lockout ledger.
grant usage on schema public to supabase_auth_admin;
grant execute on function public.hook_mfa_verification_attempt to supabase_auth_admin;
revoke execute on function public.hook_mfa_verification_attempt from authenticated, anon, public;
grant all on table public.mfa_attempts to supabase_auth_admin;
revoke all on table public.mfa_attempts from authenticated, anon, public;

The revoke is what keeps that table out of reach, not row level security, and the distinction matters here. Enabling RLS on it without also writing a policy for supabase_auth_admin would lock the hook out of its own ledger, which is why Supabase’s hook documentation tells you to “alter your row-level security (RLS) policies to allow the supabase_auth_admin role to access tables that you have RLS policies on.” A table nobody has been granted anything on needs no policy.

Then enable it, because a hook that is written and not registered is the enrolled-but-unenforced mistake one level up. The hook is selected per project under Authentication, and until it is pointed at this function the only limit in force is the per-IP one.

Ten guesses per fifteen minutes against a factor puts the attacker’s odds at roughly 1 in 33,000 per window, and no number of addresses changes that, because the ledger is keyed on the factor.

Do the second half of that arithmetic, though, because a per-window budget is a rate limit and not a stop. Ten per fifteen minutes is forty an hour, which is 350,000 guesses in a year, which is past the 231,000 median: an attacker willing to grind one account for a year and simply wait out each lockout gets to about a 65% chance. That is the reason for hard_stop and for the reject branch above, and it is the second bullet in Supabase’s own list for this hook, “sign out users who have too many invalid verification attempts.” A hundred wrong codes with no correct one in between is not a user who keeps fumbling, it is a machine, and the right response is to end the session and make somebody authenticate again rather than to keep politely returning 429 forever.

The tradeoff to make deliberately: any per-account ceiling is a denial of service an attacker can aim at a user, since anyone who knows an email can burn that account’s budget. That is why the ceiling here ends a session rather than disabling the factor, and why a correct code clears the ledger completely. Locking the factor itself would hand an attacker who cannot log in the ability to stop the owner from logging in either.

This is what OWASP’s MFA cheat sheet means by “apply strict attempt limits.” The same page adds a second rule worth implementing while you are here: “invalidate the OTP on successful verification,” so a code that has been used once cannot be replayed by anyone who observed it.

The recovery path nobody put a factor on

Shape four is where MFA programs go to die, because recovery is designed by whoever was handling support tickets, under pressure to stop locking people out.

OWASP is specific about which operations must re-verify an existing factor: “changing passwords or security questions. Changing the email address associated with the account. Disabling MFA.” The last one is the one that gets skipped, and it is the only one that matters to an attacker holding a password. If the settings page lets an aal1 session turn the factor off, the factor is not a control, it is a preference.

Password reset is the same door from outside. A reset flow that emails a link and mints a session on redemption is, functionally, a login path, and if it does not demand the second factor it is a complete bypass for anyone who can read the inbox or steer the link, which is exactly the reachable condition described in password reset poisoning. Our own reset implementation shows the shape of the decision without solving this particular problem: a completed reset revokes every existing session and deliberately returns no new session token, so redemption is not itself a login. That is the right instinct. It is not a second factor.

For recovery codes, OWASP suggests “providing the user with a number of single-use recovery codes when they first setup MFA.” Treat them as what they are, which is a set of passwords that skip your strongest control, so they get hashed at rest with the same care as a password, consumed exactly once, and counted by the same per-factor budget as everything else. A recovery code list stored in plaintext, or checkable an unlimited number of times, moves the attacker’s target from a rotating six-digit code to a fixed string.

Taking the session instead of the factor

Shape five concedes the factor entirely. The attacker lets the user complete MFA correctly and takes what comes out the other end, which is why it is unaffected by every fix above.

The clearest account of it is Retool’s own writeup of its August 2023 breach, which is worth reading because the company published the mechanism rather than a summary. An employee received an SMS pointing at a fake internal identity portal during a real Okta migration, logged in, and then took a phone call from an attacker who “claimed to be one of the members of the IT team, and deepfaked our employee’s actual voice.” The employee, already suspicious, “did provide the attacker one additional multi-factor authentication (MFA) code.” That single code let the attacker enroll their own device.

Then comes the part that made a phished account into a breach of internal systems. Retool’s conclusion: “Google recently released the Google Authenticator synchronization feature that syncs MFA codes to the cloud. This is highly insecure, since if your Google account is compromised, so now are your MFA codes.” And the consequence: “getting access to this employee’s Google account therefore gave the attacker access to all their MFA codes. With these codes (and the Okta session), the attacker gained access to our VPN, and crucially, our internal admin systems.”

The property being described is precise, and it is documented by Google today rather than being a matter of dispute. Google’s Authenticator help page says “with Google Authenticator, you can synchronize your verification codes across all your devices, simply by signing in to your Google Account,” and that “Google encrypts Authenticator codes both in transit and at rest across our products.” Encrypted in transit and at rest is a real protection against a different attacker. It is not end to end, so possession of the Google account is possession of the codes, and a TOTP seed that syncs is a shared secret whose blast radius is now the account it syncs to.

The generalization is the one that should change your architecture. A second factor authenticates a moment. What the moment produces is a session, and that session is a bearer token: whoever holds it is the user, with no further challenge, which is the whole subject of session hijacking. Modern phishing kits proxy the real login page, so the victim completes the genuine TOTP prompt against the genuine server and the kit keeps the resulting cookie. The factor worked. It was never the thing being attacked.

Two responses actually address this rather than moving it. Shorten what a stolen session is worth, which is the argument in JWT expiration, and bind the factor to the origin so a proxied page cannot use it. OWASP’s cheat sheet identifies which factors have that property: “U2F tokens are resistant to phishing since the private key never leaves the token,” and it lists passkeys as resistant too, while noting that SMS, email and passwords are all “susceptible to phishing.” A TOTP code is a string a human can read aloud on a phone call, which is precisely what happened to Retool. A passkey is not.

What a scan can see, and what it cannot

Be exact here, because a vague claim is worse than a narrow one. MFA bypass is not a property a scanner can read off your application.

A passive scan of a deployed URL cannot detect any of the five shapes. Four of them are authorization behavior that only appears when an authenticated session is exercised twice, once with a factor and once without, and doing that requires credentials for a real account. The fifth is traffic. Proving the guessing attack means actually submitting codes, which is an attack rather than an assessment. Our free surface scan does not attempt it, and neither does any of our engines.

What the passive layer does grade is the door the factor is bolted to, and one finding there is unambiguous. form-posts-over-http is HIGH, and its text is deliberately blunt: “whatever a visitor types into it, including a password, is sent unencrypted and can be read or altered on the way.” A TOTP code typed into a plaintext form is readable by anyone on the path, and the skew above means it stays valid for up to 90 seconds after it was generated, so an observer has a usable window unless the server invalidates codes once used. The three cookie-* checks in the same layer grade the session that a successful MFA challenge produces, which given shape five is the artifact actually worth protecting. Both are checkable with the cookie security checker.

Static analysis is no better placed, and it is worth saying why rather than implying a gap we could close. An aal check missing from a policy is an absence, and rules match presence. A login path added without a factor looks exactly like a login path, because it is one. A rate limit keyed on IP instead of on a factor is a correct rate limit with a defensible design, so any rule flagging it would fire on working code constantly. And an application on a managed auth provider contains no MFA verification code at all, so a clean result across every engine says only that you wrote none, which is the intended arrangement.

This is a class where the five-layer scan we describe in the release gate is honest about reaching its limit. It reads code, dependencies, secrets, configuration and the running surface. Whether your second factor is enforced on the path an attacker will choose is a question about your authorization model, and it is answered by enumerating paths, not by a scan. The platform-specific starting points are in the Lovable and Bolt writeups, and the pattern there is the one to expect: generated apps get a working login quickly and acquire additional login paths as features, each one an independent opportunity to forget the factor.

MFA is a property of a path, not of an account

Every bypass here comes from the same category error. Teams record MFA against a user, as a boolean on a row, and then reason about it as though that boolean were a fact about the system. It is not. It is a fact about one person’s enrollment. Whether a second factor stands between an attacker and your data is a property of each individual route that can produce a session, and of each individual query that returns a row.

That reframing gives you a checklist that is actually finishable. List the paths that can mint a session, and confirm every one of them either demands the factor or cannot produce a session that reads anything sensitive. List the operations that can remove or replace the factor, and confirm each re-verifies it first. Put the attempt budget on the factor rather than on the address. And assume the session that comes out of a successful challenge will eventually be stolen, so that when it is, the theft costs an attacker minutes instead of the account.

SonicWall’s bug was a user with two spellings and a guard on one of them. That is the whole genre. The only question worth asking about your own app is how many spellings it accepts.

ShareXLinkedIn