Christian Lehnert — Linux, Hacking & Faith

Storing Passwords - Why the Fast Hash Is the Bug

Christian Lehnert2026-07-21~10 min read

Storing Passwords

The One Cryptography Problem You Cannot Avoid

Most engineers can go a whole career without implementing a cipher,
designing a key exchange, or touching a signature scheme. Those are
specialist problems, and the correct instinct — do not roll your own
— keeps most people safely away from them.

Password storage is the exception. If you build anything with
accounts, you store credentials, and you have to decide how. It is
the one piece of applied cryptography that lands on the desk of
essentially every backend engineer, and it is the one I most
reliably find done wrong in engagements. Not exotically wrong.
Wrong in the same handful of ways, over and over, all of which come
from the same root misunderstanding.

The misunderstanding is that a hash is a hash. You need to store
something that lets you check a password without keeping the
password, a hash does that, so you reach for the hash function you
know. That function is almost always one built to be fast, and for
passwords, fast is precisely the bug.

Why Fast Is the Bug

A cryptographic hash like SHA-256 is engineered to be fast. That is
a virtue for its actual jobs: verifying file integrity, building
Merkle trees, content-addressing a chunk store. You want to hash a
gigabyte of data at the speed of the disk, and SHA-256 obliges,
running at hundreds of megabytes per second per core.

For passwords, that same speed is a gift to the attacker. Think
about the two parties. You, the defender, hash exactly one password
per login: one hash, once, when the user signs in. The attacker who
has stolen your database hashes billions of candidate passwords
against each stolen entry, trying to find one that matches. The
speed of the hash function helps you once and helps the attacker
billions of times. Any property that scales with the number of hash
operations is an attacker's property, because the attacker does
vastly more hash operations than you do.

The numbers are stark. A modern GPU tests weak fast hashes at over a
hundred billion guesses per second. Against a database of SHA-256
password hashes, every password that a human actually chose — every
Summer2026!, every reused password from a previous breach, every
predictable pattern — falls in minutes to hours. The hash did not
protect anything. It slowed the attacker down by a factor that
rounds to zero.

The entire discipline of password storage is the discovery that the
function has to be deliberately slow, and slow in a way you can
turn up over time as hardware gets faster. Everything else is
detail.

The Detail That Comes First: Salt

Before the slowness, one problem has to be solved, because
otherwise the attacker does not even need to be fast.

If you hash every password with the same function and nothing else,
then identical passwords produce identical hashes. The attacker does
not have to crack each entry separately. They precompute a table of
hashes for common passwords once — a rainbow table — and look up
your entire database against it. Ten million users who chose
password123 all have the same hash, and one table lookup breaks
all of them.

A salt defeats this. The salt is a random value, unique per
password, stored alongside the hash in plaintext. It is not a
secret. Its entire job is to be different for every user, so that
identical passwords produce different hashes and the attacker cannot
amortize any work across accounts. Precomputation becomes useless
because the attacker would need a separate table for every salt, and
the salt is unpredictable.

Here is the good news that shapes everything below: every modern
password hashing function generates and manages the salt for you.
You do not pick it, store it separately, or concatenate it yourself.
It is embedded in the output string. The moment you use a proper
password hash, the salt problem is solved without you thinking about
it, which is exactly how a well-designed primitive should behave.

The Functions That Do It Right

There are four functions worth knowing in 2026, and for new code the
choice is essentially made for you.

Argon2id is the answer for new projects. It won the Password
Hashing Competition in 2015, was standardized as RFC 9106 in 2021,
and is the current OWASP and NIST recommendation. Its key property
is that it is memory-hard: it deliberately uses a large amount of
RAM per hash, and memory is the thing that hurts GPU and ASIC
attackers most, because you can put thousands of cores on a card far
more cheaply than you can put gigabytes of fast memory behind each
one. Argon2id has three knobs: memory cost, time cost (iterations),
and parallelism.

The 2026 OWASP parameters as a starting point are memory 19 MiB,
iterations 2, parallelism 1 as the floor, with 64 MiB and iterations
3 being a better target if your server can absorb it. The rule that
matters more than the exact numbers: tune the parameters so a single
hash takes somewhere around 250 milliseconds on the hardware you
actually run on, and increase memory before you increase iterations,
because memory is what crushes the attacker. Re-benchmark yearly;
the right cost is a moving target that tracks hardware.

bcrypt is what you keep if you already have it. It dates from
1999, it is proven, and it is still safe at a sufficient work
factor. For new code in 2026 there is little reason to choose it
over Argon2id, but a codebase already on bcrypt does not urgently
need to move. If you stay, the work factor floor is 12, with 13 if
the server can absorb the latency under 250 milliseconds.

bcrypt has one genuine footgun worth stating explicitly: it
silently truncates the input at 72 bytes. A user with a long
passphrase gets only its first 72 bytes hashed, and everything past
that contributes nothing. If your application permits long
passwords, you must pre-hash the password with SHA-256 or SHA-512
before handing it to bcrypt, to compress it into the length bcrypt
actually reads. This pre-hash is deterministic and unsalted on
purpose — you are not adding security here, you are compressing —
and forgetting it means long passphrases are weaker than short ones,
which is the opposite of what your users expect.

scrypt is memory-hard like Argon2 and perfectly safe. If you are
already on it, stay; it was not broken, it was leapfrogged. For new
projects, Argon2id solves the same problem with a cleaner standard
and better tuning, so there is rarely a reason to newly choose
scrypt.

PBKDF2 is the one you use only when a FIPS-140 compliance
requirement forces you to a NIST-approved primitive. It is not
memory-hard, which makes it the weakest of the four against modern
GPU attacks, and it survives only on its regulatory status. If you
are forced to it, OWASP's 2026 floor is 600,000 iterations of
PBKDF2-HMAC-SHA256. If you are not forced to it, do not choose it.

What Using One Actually Looks Like

The practical shape, in Python with argon2-cffi, is the entire
implementation most applications need:

 1from argon2 import PasswordHasher
 2from argon2.exceptions import VerifyMismatchError
 3 
 4ph = PasswordHasher()   # sensible modern defaults, tune for your hardware
 5 
 6def hash_password(password: str) -> str:
 7    # Returns a self-describing string: algorithm, parameters, salt,
 8    # and hash, all encoded together. Store this one string.
 9    return ph.hash(password)
10 
11def verify_password(stored_hash: str, password: str) -> bool:
12    try:
13        ph.verify(stored_hash, password)
14        return True
15    except VerifyMismatchError:
16        return False

Two things about this are worth noticing.

The stored value is a single self-describing string, something like
$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>. It carries its own
algorithm identifier, its own parameters, and its own salt. You
store one string per user and the verification function reads
everything it needs out of it. You never manage the salt, never
store parameters in a separate column, never concatenate anything.
The primitive is designed so that the naive usage is the correct
usage.

The comparison happens inside verify, and it is constant-time.
This matters and it is the kind of thing people reintroduce as a bug
when they hand-roll: if you compare hashes with a normal
byte-by-byte equality that returns early on the first mismatch, the
time it takes leaks information about how many leading bytes matched,
and that timing side channel can be exploited. The library's verify
compares in constant time. Do not replace it with ==.

The Parameter String Makes Migration Free

The self-describing string has a consequence that solves the hardest
operational problem in this whole area: upgrading.

Because every hash carries the parameters it was made with, you can
raise your work factor, or migrate from bcrypt to Argon2id, without
a flag day and without forcing password resets. The pattern is
rehash-on-login. When a user authenticates, you verify against their
stored hash using whatever algorithm and parameters it encodes.
Then, if those parameters are below your current target, you
transparently rehash the password they just gave you with the new
parameters and store the result.

1def verify_and_maybe_upgrade(stored_hash, password, store_fn):
2    try:
3        ph.verify(stored_hash, password)
4    except VerifyMismatchError:
5        return False
6    if ph.check_needs_rehash(stored_hash):
7        store_fn(ph.hash(password))   # upgrade in place, silently
8    return True

Within a few months, everyone who logs in has been migrated to the
stronger parameters, with no user-visible event and no mass reset.
The users who never log in keep their old hashes, which is fine,
because a dormant account is not being attacked in real time and
will be upgraded the moment it wakes up. This is the mechanism that
lets a system's password security improve continuously over years
instead of in disruptive migrations, and it exists only because the
hash string describes itself.

Pepper, Briefly and Honestly

A pepper is a server-side secret that is combined with the password
before hashing, stored in your secrets manager rather than in the
database. Its purpose is defense in depth for one specific scenario:
if your database leaks but your application secret does not, the
stolen hashes are uncrackable without the pepper, because the
attacker is missing an input.

It is cheap and it is worth doing for high-value systems, with two
honest caveats. It only helps if the pepper genuinely lives
somewhere the database breach does not reach, which means a real
secrets manager, not a constant in the same repository as the
schema. And it provides no benefit at all against an attacker who
has compromised the application server itself, because at that point
they have the pepper too. It is a specific mitigation for the
database-only breach, valuable in that case and useless outside it.
Add it knowing exactly what it does.

What I Actually Find

To close the loop back to where this started. The findings I write
up are almost never "they used Argon2id with slightly low
parameters." They are the fundamentals:

Passwords stored with an unsalted fast hash, MD5 or SHA-1 or
SHA-256, crackable in bulk the day the database leaks. Passwords
stored with a salt but still a fast hash, which defeats rainbow
tables but not a targeted GPU run, so it is better and still broken.
A homemade scheme of sha256(salt + password) with a home-grown
salt, which reinvents a fraction of what the library does and gets
the constant-time comparison wrong. And, distressingly often,
passwords stored in a form that can be reversed at all, because
someone confused encryption with hashing and built a system that can
recover the plaintext, which means so can the attacker.

Every one of these comes from the same root: treating password
storage as a hashing problem when it is a deliberate-slowness
problem, and reaching for the fast, general-purpose hash because it
was the tool at hand.

The Takeaway

Do not hash passwords with a function built to be fast. Use a
function built to be slow and memory-hard: Argon2id for new code,
tuned to about 250 milliseconds per hash on your hardware, with the
memory cost turned up as high as you can afford. Let the library
handle the salt, store the single self-describing string it gives
you, verify with its constant-time comparison, and rehash on login
to upgrade parameters over time without ever forcing a reset.

The whole field is one idea wearing a lot of parameters: the hash
has to be slow on purpose, because you run it once and the attacker
runs it a billion times.

Tagged:
#security #cryptography #hashing
← Back to posts