All posts
VibeZero Team16 min read

Nobody guessed the password. Somebody else already lost it.

Credential stuffing logs in with real passwords leaked from other sites, so every attempt succeeds. The 23andMe case, why IP rate limits miss it, and the fixes.

  • security
  • vibe coding
  • engineering

Almost every login attack you have read about is a guessing game. Somebody throws passwords at an account until one sticks, and the whole defensive vocabulary follows from that: make the password long, make guessing slow, lock the account after five tries.

Credential stuffing is not a guessing game. The attacker already has the password. They got it from a breach at a company you have never heard of, where your user signed up in 2019 with the same email and the same eight characters they used on your site last Tuesday. There is nothing to guess and nothing to crack. The attacker’s job is reduced to typing, at volume, and finding out which of the pairs they hold still work somewhere.

That changes what a defense has to do, and it is the reason so many applications that would pass a login review get emptied out anyway. Your login form can be flawless. Every attempt in a credential stuffing run is a correct password submitted to a real account, and a correct password is the one input your authentication code was built to accept.

Three attacks that all get called brute force

The distinctions here are not pedantry, because each of the three fails against a different control. OWASP’s Credential Stuffing Prevention Cheat Sheet separates them cleanly:

  • Brute force: “Testing multiple passwords against a single account.”
  • Credential stuffing: “Testing username/password pairs obtained from the breach of another site.”
  • Password spraying: “Testing one weak password against many accounts.”

Brute force is loud against one account, so per-account lockout stops it. Password spraying is quiet against each account but uses garbage passwords, so a common-password blocklist stops it. Credential stuffing is quiet against each account and uses correct passwords, which is why it walks past both.

The class sits at the top of A07:2021, Identification and Authentication Failures, whose first listed weakness is exactly this: an application that “permits automated attacks such as credential stuffing, where the attacker has a list of valid usernames and passwords.”

Note the phrase “valid usernames.” The lists circulating are addresses and passwords together, but a list of ten million pairs is mostly dead weight, and the attacker would much rather run four hundred confirmed pairs than ten million speculative ones. That shortlist is what account enumeration provides, and it is why the two bugs are usually exploited in sequence rather than separately. Fixing enumeration does not stop stuffing. It makes stuffing expensive, which is a different and still useful thing.

18,222 accounts, and then nearly seven million people

The reference case is 23andMe, and it is worth using the regulators’ version rather than the news coverage, because the joint investigation by the UK Information Commissioner and the Privacy Commissioner of Canada put numbers and dates on record.

PIPEDA Findings #2025-001 records that between April 29 and September 20 of 2023, an attacker used credentials leaked from unrelated breaches to sign in to “18,222 customers’ accounts … worldwide, including 769 in Canada and 611 in the UK” (para. 20). Not one of those logins cost the attacker any work. The passwords were not cracked. They were looked up.

Then the amplification. Those accounts had the DNA Relatives feature switched on, which by design shows one customer the profiles of their genetic relatives, so signing in as one person returned data about thousands. The finding puts “almost 7 million additional customers” on the far side of those 18,222 logins, itemized as 5,497,376 DNA Relatives profiles and 1,468,791 family tree profiles (paras. 20 and 30). That is close to four hundred people exposed per account signed into, and the multiplier was not a vulnerability. It was a product feature working correctly for a user who was not the user.

The bill came per jurisdiction. The ICO’s penalty of £2.31 million records 155,592 UK residents whose data was exposed, against 611 UK accounts that anybody actually signed into. The two figures are not a ratio, since relatives are not sorted by country and UK residents were reached through stuffed accounts anywhere in the world. They are the same shape measured twice.

That shape is the part to carry away, and it generalizes past genealogy. The blast radius of one valid login is not one account, it is everything that account is allowed to read, which is a question about access control and about what one tenant can see of another, not about passwords at all. An app where any signed-in user can list the customer table has an authentication problem worth one account and an authorization problem worth all of them.

Why your rate limit does not see it

Ask most teams what stops credential stuffing and you will get “we rate limit the login endpoint.” The attackers know that, and their answer to it is boring.

Okta’s Identity Threat Research team published what a modern run looks like. Their April 2024 advisory covers a spike observed “from April 19, 2024 through to April 26, 2024” and describes the traffic plainly: “All recent attacks we have observed share one feature in common: they rely on requests being routed through anonymizing services such as TOR. Millions of the requests were also routed through a variety of residential proxies.”

A residential proxy network rents out real consumer connections, so the requests arrive from the address ranges your actual customers use, a handful at a time, from tens of thousands of distinct addresses. A per-IP limit is not bypassed by this so much as rendered irrelevant: it is enforcing a budget on a dimension the attacker can buy more of. OWASP’s cheat sheet says the same thing in one line, that IP blocking “should not be used as the sole or primary defense due to the ease in circumvention.”

We run a per-IP limit anyway, and it is worth being precise about what it buys, because the honest version is more useful than the marketing one. Our control plane puts every pre-authentication route behind one strict tier: a burst of 10 attempts, then one more every 6 seconds, with each violation doubling a block that starts at 6 seconds and caps at 15 minutes, decaying after 15 quiet minutes. The comment in rate_limit.rs states the intended trade in the same breath as the limitation: “a fumbling human gets several tries while credential stuffing is capped per IP.” Capped per IP. That sets the price of one address, and against a rented residential pool the attacker’s answer is to use more addresses. The module also records that storage is in memory per process, so “with N instances behind the load balancer the effective limit is N times the configured one.”

So per-IP limiting is a floor, not a defense. What actually bounds a stuffing run has to be keyed on something the attacker cannot rotate, and there are only two candidates: the account, and the credential itself.

The account side is what NIST asks for. SP 800-63B is unusually specific in section 3.2.2: “The verifier SHALL limit consecutive failed authentication attempts using a specific authenticator on a single subscriber account to no more than 100 by disabling that authenticator.” A hundred sounds high until you notice that a per-account counter is also a denial-of-service lever: set it at five and anybody can lock any customer out by guessing wrong at them. The point is not the threshold, it is that the counter is keyed on the account, so distributing the attack across ten thousand addresses does not reset it.

The password nobody checked

The credential side is the one that ends the attack rather than slowing it, and it is cheap. If the password a user is setting has appeared in a public breach corpus, it is already on the list the attacker is holding. Refuse it at the door and the pair they bought never works.

This is a requirement, not a suggestion. NIST SP 800-63B section 3.1.1.2: “When processing a request to establish or change a password, verifiers SHALL compare the prospective secret against a blocklist that contains known commonly used, expected, or compromised passwords.” The blocklist should include “passwords obtained from previous breach corpuses.”

The same section deletes most of what an AI-generated signup form will write for you. “Verifiers and CSPs SHALL NOT impose other composition rules (e.g., requiring mixtures of different character types) for passwords.” And: “Verifiers and CSPs SHALL NOT require subscribers to change passwords periodically. However, verifiers SHALL force a change if there is evidence that the authenticator has been compromised.” Length replaces both: a minimum of 15 characters where the password is the only factor, 8 where it is not, and a maximum that “SHOULD permit … at least 64 characters.”

Read those together and the standard practice most apps ship is inverted. The one-uppercase-one-digit-one-symbol regex is forbidden. The 90 day rotation is forbidden. The breach check, which almost nobody implements, is mandatory. And the 20 character maximum that quietly breaks password managers is the opposite of the requirement.

The breach check is one HTTP request, and Have I Been Pwned’s range API is built so you never send the password or its full hash. You send the first five characters of the SHA-1 and get back “the suffix of every hash beginning with the specified prefix, followed by a count of how many times it appears in the data set.” There is no API key and, per the documentation, “there is no rate limit on the Pwned Passwords API.”

// Runs at signup and at password change. Returns the number of breaches the
// password appears in; 0 means it is not in the corpus.
export async function pwnedCount(password: string): Promise<number> {
  const bytes = new TextEncoder().encode(password);
  const digest = await crypto.subtle.digest("SHA-1", bytes);
  const sha1 = Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("")
    .toUpperCase();

  const prefix = sha1.slice(0, 5);
  const suffix = sha1.slice(5);

  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
    // Pads the response to a random 800 to 1,000 records so an observer cannot
    // infer anything from its size. Padded rows come back with a count of 0.
    headers: { "Add-Padding": "true" },
  });
  if (!res.ok) return 0; // Fail open: never lock a user out of signup on an outage.

  for (const line of (await res.text()).split("\n")) {
    const [hashSuffix, count] = line.trim().split(":");
    if (hashSuffix === suffix) return Number(count);
  }
  return 0;
}

Two decisions in that snippet are worth stating out loud. The prefix is sent, never the password, so the service learns that somebody checked one of roughly half a million hashes and nothing else. And it fails open, because a signup form that breaks when a third party has an outage will be removed by whoever is on call, and a check that gets removed protects nobody.

Where this belongs is at the write, not the read: signup, password change, and password reset. Checking at login instead means holding a plaintext password that already works, which is late.

On a Supabase app, three of these are settings

Most AI-built apps do not write an authentication system. Lovable, Bolt and the rest reach for a managed provider, which is the right call and moves the question from “did we implement this” to “did we configure it.” The defaults deserve reading, because on this stack the default is what shipped.

Supabase Auth’s minimum password length comes from GOTRUE_PASSWORD_MIN_LENGTH, documented in the auth server’s README as “minimum password length, defaults to 6.” Six characters is below NIST’s floor for a password backed by a second factor and well below the floor for one standing alone. It is also a length at which the breach corpus is close to exhaustive.

The breach check itself exists and is off. Supabase’s password security guide describes it as “Supabase Auth uses the open-source HaveIBeenPwned.org Pwned Passwords API to reject passwords that have been leaked and are known by malicious actors,” and then adds the line that matters for the audience of this post: “Leaked password protection is available on the Pro Plan and above.” So an app on the free tier, which is most apps at the point where they first take real users, is not performing the breach-corpus comparison that 800-63B marks SHALL. Either upgrade the project or run the function above yourself in the signup path. Both are fine. Doing neither is the common case.

Then MFA, which OWASP calls “by far the best defense against the majority of password-related attacks,” citing analysis that it “would have stopped 99.9% of account compromises.” In the 23andMe investigation, MFA existed. It was “optional, rather than a mandatory feature on its platform, and less than 22% of 23andMe customers had opted into either MFA or Single Sign-On.” An optional control that four out of five users skip is a control that four out of five users do not have, and the remediation the company shipped afterwards was to make it mandatory (para. 116).

// Enrollment. The QR code goes on screen; the factor stays unverified until
// the user proves possession by entering a code generated from it.
const { data: factor } = await supabase.auth.mfa.enroll({ factorType: "totp" });
const { data: challenge } = await supabase.auth.mfa.challenge({
  factorId: factor.id,
});
await supabase.auth.mfa.verify({
  factorId: factor.id,
  challengeId: challenge.id,
  code: userEnteredCode,
});

The part teams get wrong is not the enrollment, it is the gate afterwards. Enrolling a factor does not restrict anything on its own: the session that comes back from a password login is a usable session, and if your row level security policies only check auth.uid(), an attacker holding a stuffed password reaches the data without ever meeting the second factor. That gap is one of five distinct ways a working second factor gets walked around, which MFA bypass covers in full: worth reading before you treat the code below as finished, because a policy on one table does nothing about a second login path, the reset flow, or a service_role key that skips row level security entirely. Supabase exposes the authenticator assurance level for this, and the check belongs in the policy rather than in the interface:

-- The session must have actually completed the second factor, not merely have
-- one enrolled. A password-only session is aal1 and reads nothing here.
--
-- `as restrictive` is load bearing: a permissive policy is OR-ed with the
-- others on the table, so writing this one permissively would add a way in
-- rather than take one away. It subtracts rather than grants, so it sits
-- alongside whichever permissive policy already scopes these rows to
-- their owner; on its own it would let nobody read anything.
create policy "aal2 required for billing rows"
on public.billing_accounts
as restrictive
for select
to authenticated
using ((select auth.jwt() ->> 'aal') = 'aal2');

What a scan can see, and what it cannot

Be precise here, because a vague answer is worse than a narrow one. Credential stuffing is not a property of your code that a scanner can read. It is traffic. Proving your app is vulnerable means submitting real leaked credentials to it, which is an attack, not an assessment, and no passive scan of a deployed URL does anything of the sort. The same is true of a secrets scan and a dependency scan. This is a configuration review and a monitoring question.

What a passive scan does grade is the doors around it. The one directly on this path is form-posts-over-http, which our free scanner raises at HIGH when a form on the page points its action at a plain http URL: “Whatever a visitor types into it, including a password, is sent unencrypted and can be read or altered on the way.” That is credential stuffing in advance, with your own users supplying the list. The cookie findings matter for the other end, since what a successful stuffed login produces is a session, and a session with the wrong attributes survives longer and travels further than it should. You can run both against a live URL with the cookie security checker and the security headers checker, and the longer version of what a stolen session is worth is in session hijacking.

Our own control plane is a useful worked example on the parts a scan cannot reach, including where it is still short.

Password storage is Argon2id, and the interesting detail is not the algorithm, it is what happens to a deliberately slow hash under a flood. (The algorithm half, including the parameters, the 72 byte limit that let an Okta bug skip the password entirely, and the second column that undid bcrypt at Ashley Madison, is password hashing failures.) A stuffing run is by construction a request pattern that forces the server to hash on every attempt, so the CPU cost you added for the attacker’s benefit is a cost you pay first. Hashing runs on the blocking pool behind a semaphore sized to the core count, and a request that cannot get a slot within 5 seconds is shed with a 503 and a Retry-After rather than queued forever, on the reasoning that “past this point the client has likely given up anyway, and hashing for a dead connection is pure wasted CPU.”

The login handler also spends one full Argon2 verification whether or not the account exists, against a fixed dummy hash, so response time cannot separate a registered address from an unknown one. That is an enumeration defense rather than a stuffing defense, and it is in this post because of the sequencing above: it is the control that keeps the attacker’s list at ten million rather than four hundred.

Failed attempts per account are counted and, on the next successful login, written into the user_login_history row as error_attempts. Which brings the honest part. That counter is in memory per process, so it does not survive a restart and is not shared across instances, and there is no per-account lockout built on it yet. Nothing surfaces the login history to the user either, which is precisely one of the failures the regulators named at 23andMe: “no device history was made available to customers to show them what devices had been, or were currently being used to access their account” (para. 87). We record what would answer that question and do not yet show it. Saying so is more useful than a page that implies otherwise, and it is the same rule this blog applied in August when reading our own clone step revealed that three earlier posts had overstated what our secret scanning covers.

A password that still works is not a password that is yours

The uncomfortable thing about this class is that it is not really a bug in your application. Nothing is malformed, no check is missing, no input is unvalidated. Your users reused a password, some other company lost it, and your login form did exactly what it was written to do.

That is also why the defenses that work are the ones that stop treating a correct password as sufficient evidence. A second factor, because possession is not in the dump. A breach corpus check, because it removes the specific credentials being traded. A per-account attempt counter, because it is keyed on the one dimension an attacker cannot rent more of. Everything else, including the rate limit we run and you should too, raises a price rather than closing a door.

And when the door does open, what decides the damage is not the login at all. 18,222 accounts became nearly seven million people because of what one ordinary signed-in user was allowed to read. So the question worth asking about your own app is not only “how hard is it to log in as somebody else.” It is the one after it: if a stranger held a valid session for your most ordinary user right now, how much of your database would come back?

ShareXLinkedIn