Bcrypt was correct. Eleven million passwords came out anyway.
Password hashing failures explained: the Ashley Madison column that undid bcrypt, the 72 bytes that let Okta skip a password, and the parameters to set.
- security
- vibe coding
- engineering
Ask a developer whether their app stores passwords safely and you will get the name of an algorithm back. Bcrypt. Argon2. Something with a cost factor. The answer is treated as a yes or no question with a one word answer, which is roughly how the topic gets taught, and it is why so many teams who can name the right algorithm still lose every password they hold.
The name is the least interesting part. Ashley Madison used bcrypt at a cost factor higher than the minimum OWASP recommends today, and eleven million of its passwords came out anyway, in days rather than years, because of a different column in the same table. Okta used bcrypt and shipped three months during which some accounts could be signed into without the correct password, because of what bcrypt does with its 73rd byte. In neither case was the algorithm broken. In both cases the algorithm was correct and irrelevant.
This post is about the gap between “we hash passwords” and “hashing our passwords accomplishes something,” which turns out to be wide enough that the name of the algorithm tells you almost nothing about which side you are on.
Five ways to get this wrong, and only one of them is the algorithm
It helps to separate the failure modes, because they have almost nothing in common and a team that has closed one usually has not looked at the others.
A fast hash. MD5, SHA-1, SHA-256, or anything else built to be quick. These are the right tool for checksums and the wrong tool for passwords for exactly the reason they are good at checksums: a GPU does billions of them a second. A password hash has to be slow on purpose.
No salt. Identical passwords produce identical hashes, so one precomputed table cracks every account at once and the attacker learns, for free, which of your users share a password.
The right algorithm at the wrong settings. The cost factor is an exponent, so each step doubles the work: bcrypt at cost 4 is still bcrypt, and it is 64 times cheaper to attack than bcrypt at cost 10. The name in your dependency list tells a reader nothing about this.
The right algorithm, correctly configured, and a second copy of the password somewhere else. This is the Ashley Madison failure and it is the one nobody looks for, because the review question is “do we hash passwords” and the answer is honestly yes.
The right algorithm quietly ignoring part of the input. This is the Okta failure. Bcrypt reads 72 bytes and discards the rest, silently, in most implementations.
Only the first two are what people mean by “password hashing failures,” and they are the two that a code review catches. The last three are where the real incidents come from.
Ashley Madison: the bcrypt hashes were never attacked
In 2015, roughly 36 million account records from Ashley Madison were published. The passwords were bcrypt hashed, and CynoSure Prime records that “the developers used a cost factor of 12 for the bcrypt hash,” which is two steps above the minimum OWASP recommends a decade later and was generous for its time. That was widely reported as the one thing the company had done right, and it was true: nobody has ever produced those passwords by attacking bcrypt.
They did not need to. The password cracking group CynoSure Prime went through the leaked application source instead of the hashes, and found that the codebase computed a second, unrelated token from the same password. Their writeup gives the formula it reconstructed:
md5(lc($username)."::".lc($pass))
A second variant folded in the email address and a fixed string lifted straight from the application source:
md5(lc($username)."::".lc($pass).":".lc($email).":<constant from the source>")
Read what that is. The username is a known value, sitting in the same dump. So is the email. The constant is in the source. So the only unknown in an MD5 digest is the password, lowercased, and MD5 is the fastest thing an attacker owns. The expensive hash and the cheap hash were computed from the same secret and stored side by side, which means the security of the pair is the security of the cheap one.
The recovery was mechanical after that. Crack the MD5 token to get the lowercase password, then, as CynoSure Prime put it, “we simply then had to case correct it against its bcrypt counterpart,” which is a handful of bcrypt operations per account instead of a search. They recovered “over 11.2 million of the bcrypt hashes” in “days, not years,” and the first tranche, “more than 2.6 million passwords,” fell in “just a few hours” using “one CPU box only.”
The other detail worth carrying: this was a migration artifact. The token predated bcrypt, and when the team moved to bcrypt they did not go back and regenerate the old tokens. That is the ordinary shape of it. Nobody decides to store an MD5 of the password. Somebody upgrades one path and leaves the other one alone, and the upgrade is recorded as done.
The 72 bytes at the end of bcrypt
Bcrypt has a fixed input limit. OWASP’s Password Storage Cheat Sheet states it plainly: “bcrypt has a maximum length input length of 72 bytes for most implementations, so you should enforce a maximum password length of 72 bytes.”
The word doing the work is most. Historically, implementations did not reject a longer input, they truncated it, and did so without telling anybody. That is mostly tolerable when the input really is a password, since a password long enough to reach the limit is past what most people type. Note that the limit is in bytes and not characters, so a passphrase, or any alphabet where a character costs two or three bytes, arrives there sooner than “72” suggests. And it stops being tolerable at all the moment somebody feeds bcrypt something other than a password.
Which is exactly what Okta did. Their advisory describes the mechanism in one sentence: “The Bcrypt algorithm was used to generate the cache key where we hash a combined string of userId + username + password.”
The password is last in that concatenation, which is the whole bug. Once
userId + username alone reaches 72 bytes, everything after it is discarded, and
what comes after it is the password. Two logins for the same account with two
different passwords then produce the same cache key, so a cached success matches
a request that carries no correct password at all. The advisory’s 52 character
threshold is simply 72 minus whatever the user id contributed in front.
The advisory lists the preconditions, and they are worth reading as a set because each one is ordinary on its own: Okta AD/LDAP delegated authentication in use, MFA not applied, a username of 52 characters or longer, a previous successful authentication that created a cache entry, and the cache being consulted first because the agent was unreachable. The window ran from July 23rd to October 30th of 2024, and the fix was to move that cache key “from Bcrypt for PBKDF2.”
Three months, in an identity provider, from a bug whose entire content is that a hash function has a documented input limit and somebody used it on a longer string.
Languages are starting to close this off at the source. A
commit to Go’s x/crypto/bcrypt
made GenerateFromPassword return ErrPasswordTooLong (“bcrypt: password length
exceeds 72 bytes”) rather than truncate, and its message is blunt about the prior
state of the art: “Most implementations, including the reference one, simply
silently ignore any trailing input when provided passwords longer than 72 bytes.”
It is equally blunt about the half it could not fix: “CompareHashAndPassword will
still accept these passwords, since we cannot break hashes that have already been
stored.” So the write path is now loud and the read path is still silent, which is
the correct compromise and also means you cannot assume the language caught it
for you.
If you are on bcrypt, two lines of policy close this: reject anything over 72 bytes at signup rather than letting it through, and never pass bcrypt a concatenation of anything. If you need a fixed-length input, HMAC it first, which is the pre-hashing construction OWASP recommends anyway.
The parameters, which are the part people guess at
This is the section most posts leave vague, so here are the actual numbers with their source.
OWASP’s recommendation for Argon2id is a floor and a set of equivalent tradeoffs:
“Use Argon2id with a minimum configuration of 19 MiB of memory, an iteration
count of 2, and 1 degree of parallelism.” The alternatives it lists trade memory
against time at roughly equal strength: m=19456, t=2, p=1, then m=12288, t=3, p=1, then m=9216, t=4, p=1, then m=7168, t=5, p=1. Pick the first one unless
you know why you cannot.
For the others: bcrypt’s “work factor should be as large as verification server
performance will allow, with a minimum of 10.” PBKDF2-HMAC-SHA256 wants 600,000
iterations, PBKDF2-HMAC-SHA512 wants 220,000, and PBKDF2-HMAC-SHA1 wants
1,400,000 and is legacy only. Scrypt wants N=2^17 (128 MiB), r=8, p=1.
NIST is the other half of the requirement, and SP 800-63B section 3.1.1.2 is short enough to quote whole in the parts that matter. “Passwords SHALL be salted and hashed using a suitable password hashing scheme.” On the salt: “The salt SHALL be at least 32 bits in length and chosen to minimize salt value collisions.”
And then the requirement almost nobody implements, which is where a pepper belongs:
Verifiers SHOULD perform an additional iteration of a keyed hashing or encryption operation using a secret key known only to the verifier… The secret key value SHALL be stored separately from the hashed passwords. It SHOULD be stored and used within a hardware-protected area, such as a hardware security module or trusted execution environment (TEE).
The reasoning is about one specific breach shape, and it is a shape you can reach several ways: read access to the database without control of the application host. SQL injection, a backup left readable and a misconfigured row level security policy all land there. If the key needed to attack the stored values lives somewhere other than that database, a dump of the table is inert. The catch is that the key has to actually live somewhere else: committed next to the code that reads it, it is one secret in git history away from turning the table back into a corpus, and the control has bought nothing while appearing on every architecture diagram.
Migrating off a fast hash without a mass password reset
Say you inherited an app with MD5 in the password column. The obvious fix is to
wrap it: store bcrypt(md5(password)) for every existing row, and you are
upgraded without asking anyone to do anything.
This has a name, and it is a mistake. It is called password shucking, and Scott Brady’s writeup lays out the arithmetic. The attack needs two conditions, and neither is exotic: “the target user appears in existing breaches that used your old password hashing algorithm” and “the target user has re-used passwords across websites.”
Given those, the attacker does not attack your bcrypt at all. They take unsalted MD5 digests from some earlier public breach, run each one through your bcrypt with your salt and your cost, and look for a match. A hit tells them the inner MD5 digest for that account, and from there they are cracking MD5 rather than bcrypt. Brady’s numbers: “Attacking the bcrypt layer directly with hashcat would only allow you around 2,000 guesses per second. Attacking the known MD5 hash, discovered using password shucking, would enable you 64,000,000,000 guesses per second.”
A factor of thirty two million, and the reason it is available at all is that the inner value was a bare unsalted digest somebody else had already published.
The fix is to make the inner value something no other database contains, which
means keying it. OWASP’s pre-hashing construction is
bcrypt(base64(hmac-sha384(data:$password, key:$pepper)), $salt, $cost), and the
same idea applies to the migration:
import { createHmac, createHash } from "node:crypto";
import argon2 from "argon2";
// Read from a key vault or the process environment, never from the database
// the hashes live in. If this value leaks, you are back to plain wrapped MD5,
// which is the situation this whole function exists to avoid.
const PEPPER = process.env.PASSWORD_PEPPER!;
// The keyed wrap. An MD5 digest that some other breach also contains stops
// being a lookup key here, because nobody outside this process can compute
// what it turns into.
const wrap = (legacyDigest: string) =>
createHmac("sha384", PEPPER).update(legacyDigest).digest("base64");
The migration itself is offline and needs nobody to log in, which is the whole point: you cannot wait for forty thousand dormant users to visit before the table stops being a liability.
// One pass over the legacy rows. No plaintext password is involved anywhere,
// because there is none to involve.
//
// Watch the encoding. Whatever the legacy column holds (lowercase hex here) is
// what the login path below has to reproduce byte for byte. Get it wrong and
// every migrated user is locked out at once, with the only values that could
// have told you already overwritten.
for (const user of await db.user.findMany({ where: { scheme: "md5" } })) {
await db.user.update({
where: { id: user.id },
data: {
passwordHash: await argon2.hash(wrap(user.passwordHash)),
scheme: "argon2id-over-hmac-md5",
},
});
}
Then the login path verifies against whichever scheme the row records, and upgrades it in the one moment the plaintext is legitimately in memory:
export async function verifyPassword(user, password: string): Promise<boolean> {
const candidate =
user.scheme === "argon2id-over-hmac-md5"
? wrap(createHash("md5").update(password).digest("hex"))
: password;
if (!(await argon2.verify(user.passwordHash, candidate))) return false;
// Correct password, in hand, for the only moment it will ever be. Rehash it
// directly so this row leaves the legacy scheme behind for good. Failing
// here must not fail the login: the user authenticated successfully, and a
// write error is our problem, not theirs.
if (user.scheme !== "argon2id") {
await db.user
.update({
where: { id: user.id },
data: { passwordHash: await argon2.hash(password), scheme: "argon2id" },
})
.catch((err) => logger.error({ err, userId: user.id }, "rehash failed"));
}
return true;
}
That scheme column is not incidental bookkeeping, it is what makes any of this
possible. Recording per row how a value was produced is the difference between a
migration you can run gradually and one that has to happen everywhere at once.
Argon2 and bcrypt both do a version of this for free, encoding their parameters
into the PHC string they emit, which is why an old hash keeps verifying correctly
after you raise the cost for new ones: rotating a cost factor is only safe
because the stored value remembers what it was made with. The column extends the
same idea to the part the hash string cannot describe, namely what was fed to it.
One more decision, borrowed from Brady: give the legacy scheme a deadline. Three months of rehash-on-login converts everyone who is still active, and then the remaining rows get deleted rather than carried forever. A dormant account whose password hash you have thrown away is an account that has to use the reset flow, which is a much better outcome than a wrapped MD5 sitting in production indefinitely. Which makes that reset flow load bearing, so it is worth confirming it is not itself the way in: see password reset poisoning.
On a managed provider you are configuring, not implementing
Most AI-built apps do not write any of the above. Lovable, Bolt and the rest reach for a managed auth provider, which is the correct call and changes the question rather than removing it. You are no longer choosing an algorithm. You are inheriting one, and you should know which.
Supabase Auth’s
password security guide
says it uses “bcrypt, a strong password hashing function, to store hashes of
users’ passwords,” in “the encrypted_password column of the auth.users
table,” each with “a randomly generated salt parameter.” The cost is visible in
the source:
GenerateFromPassword starts with hashCost := bcrypt.DefaultCost, and Go’s
bcrypt.DefaultCost is 10.
Ten is exactly OWASP’s stated minimum, which is worth sitting with for a second.
It is not wrong. It is the floor, and it is what a Supabase project is running,
because a default is what ships. The only other branch in that function goes the
other way (case QuickHashCost: hashCost = bcrypt.MinCost, which is 4, for test
environments). Nothing in it raises the cost.
What you do control on that stack is everything around it, and the defaults there
are weaker than the hashing is. GOTRUE_PASSWORD_MIN_LENGTH
defaults to 6. Leaked password protection,
which compares a new password against the breach corpus, exists and is “available
on the Pro Plan and above.” A six character password checked against nothing is
not a password your hashing parameters can rescue. A cost factor buys time
against a search, and a stuffed credential is never searched for. It is looked
up, which costs exactly one hash whichever cost factor you chose. That is the
argument in
credential stuffing, and it is the reason
this post’s parameters are the second thing to fix rather than the first.
Two things stay yours no matter which provider you use. The password should reach
the provider’s endpoint and nothing else, so a form that posts credentials to
your own backend first, to log them or forward them, undoes the arrangement you
are paying for. And whatever your app derives
from a password, it derives on its own account, which puts you back in Ashley
Madison’s position with none of the provider’s protection helping. If your
codebase computes anything at all from password besides handing it to the SDK,
that line is the one to review.
What a scan can see, and what it cannot
Be precise here, because a vague answer is worse than a narrow one.
A static analysis pass reads your source, so it sees the fast-hash failure well.
Our pipeline runs Bandit
on Python repositories, where B324 tests for “insecure md4, md5, or sha1 hash
functions in hashlib” and reports at high severity and high confidence; our engine
builds each finding’s title from bandit’s own test name and id rather than a
description we invented, so it reaches your board carrying (B324). On Go, gosec
contributes G401 (“Detect the usage of MD5 or SHA1”) along with the import
blocklist rules G501 for crypto/md5 and G505 for crypto/sha1. On JavaScript
and TypeScript the same ground is covered by OpenGrep, which runs the rule sets
matching whichever languages the detector found. If somebody hashed a password
with MD5 in code we can read, this is caught.
Now the limits, in order of how much they matter.
A scanner reads code, not columns. Nothing in a static pass can tell you what
is actually in users.password_hash today. A repository can be immaculate and the
table can still be full of rows written by a version of the app that was deleted
two years ago, which is precisely the Ashley Madison shape. The only way to know
is to select a row and look at its prefix.
Parameters usually are not literals. bcrypt.hash(password, cost) with a
cost read from configuration is invisible to a rule matching on the call. A
scanner sees the algorithm far more reliably than it sees the settings, and the
settings are half the question.
The Okta bug is not a pattern. Hashing a concatenated string is a normal
thing to write and correct in most contexts. What made it a vulnerability was the
combination with a specific input length and a cache-first fallback, and no rule
that fires on bcrypt(a + b + c) would be tolerable, because it would fire
constantly on correct code.
On a managed provider there is nothing in the repo to find. A Lovable app using Supabase Auth contains no hashing code, so a clean SAST result on password storage means only that you did not write any. That is the common case for this audience, and it is why a passing scan is not an answer to this question.
What our passive scanner does grade is the step before the hash. The
form-posts-over-http check fires at HIGH when a form on the page points its
action at a plain http URL, with the finding text saying why: “Whatever a visitor
types into it, including a password, is sent unencrypted and can be read or
altered on the way.” A password read off the wire is a plaintext password in
somebody else’s hands, and what your server does with its own copy a millisecond
later is beside the point. It is the one finding in this area that no cost factor
compensates for. On the other end, what a successful login produces is a session,
and the cookie security checker grades the
attributes on it, with the longer version in
session hijacking. And if you implement a
pepper, the exposed API key scanner reads the
JavaScript a deployed page actually loads, which is where a secret ends up when
someone reaches for it from the wrong side of the app.
Our own control plane is a useful worked example, including where it falls short.
Password storage is Argon2id through the argon2 crate at 0.5, using
Argon2::default(), which resolves to m=19456 (the crate writes it as
19 * 1024), t=2, p=1. That is OWASP’s first recommended configuration
exactly, and the honest way to say that is: it matches because the crate’s
defaults match, not because we wrote those numbers down. Our source names no
parameters, so a future release that changes its defaults would move ours without
a line of our code changing. Stored hashes are safe either way, since the PHC
string carries its own parameters, but the sentence “our parameters are OWASP’s”
is a fact about a dependency rather than about us, and pinning them explicitly is
the obvious next change.
The operational half is the part that took actual thought. A deliberately slow
hash is a CPU cost you inflict on yourself on every login attempt, including the
attempts you did not want. Hashing runs on the blocking pool behind a semaphore
sized to the core count, because spawn_blocking alone grows to 512 threads by
default and a burst of sign-ins would run that many memory-hard hashes at once. A
request that cannot get a slot within 5 seconds is shed with a 503 and a
Retry-After rather than queued, on the reasoning that “past this point the
client has likely given up anyway, and hashing for a dead connection is pure
wasted CPU.” Both hashing and verification take a slot, including the fixed dummy
verification spent on unknown emails, so queueing delay is uniform and adds no
account enumeration timing signal. That
last detail is a real constraint on the design: the moment you make hashing
expensive, its cost becomes an oracle, and every path that skips it becomes a
distinguishable one.
Two gaps we have not closed. There is no pepper: NIST marks the keyed second
iteration SHOULD, and our hash sits in the same row as the email it belongs to,
so a read of that table is a crackable corpus rather than an inert one.
And MIN_PASSWORD_LEN is 8, which is the number NIST allows only for a password
“used as part of multi-factor authentication processes.” For a password carrying
the authentication on its own, which is ours, section 3.1.1.1 says verifiers
“SHALL require passwords that are used as a single-factor authentication
mechanism to be a minimum of 15 characters in length.” We are seven short of that
because we have no second factor to trade against it. Saying so is more useful
than a page that implies otherwise.
The hash is the last line, not the first
The reason password hashing gets discussed as an algorithm choice is that the algorithm choice is the only part with a clean right answer. Use Argon2id at 19 MiB, two iterations, one degree of parallelism. That sentence is correct, complete, and the smallest part of the job.
Because what a password hash is for is narrow. It is a control that assumes your database is already gone, and its only job is to make the copy the attacker is holding worthless. It does nothing about a password sent over http, nothing about a password that was already in someone else’s breach, nothing about a session minted after a correct login, and nothing whatsoever about the second value your codebase computed from the same string three years ago and forgot.
So the useful audit is not “which algorithm.” It is two questions, asked in order. First: everywhere in this codebase that the plaintext password variable is read, what happens to it, and is any of that a second door? Second: if somebody selected the entire users table tonight and left with it, what would they need that they would not have?
Ashley Madison could have answered the first question in an afternoon with a grep, and the answer would have been eleven million passwords.