← Back to Logs

How Password Hashing Actually Works

Try the interactive lab for this articleTake the quiz (6 questions · ~4 min)

If your application has users, it probably has a database column called password_hash and a function called verify_password. And almost every week, a breach somewhere in the world turns a copy of some company's user table into a public dataset. Whether the people in that table are safe depends almost entirely on what that hash column actually contains.

A password hash is a one-way function's output applied to a user's plaintext password. In theory, if the function is truly one-way, an attacker who steals the database cannot recover the passwords. In practice, "one-way" is a relative term: every hash function can be inverted by guessing inputs and comparing outputs, and the only real question is how expensive that guessing game is. The entire field of password hashing is about making that guessing game as expensive as possible for the attacker, while keeping it cheap enough that legitimate users can log in without waiting.

This article explains how modern password hashing actually works, what the different algorithms buy you, why MD5 and SHA-256 are specifically the wrong answer, and how to pick and tune a hash function for a real application. It also covers the operational side: salts, peppers, pepper rotation, rehashing, timing attacks, and what to do when your hash function goes out of fashion.

A Short History Of Storing Passwords On Unix

In the very earliest Unix systems, passwords were stored in plaintext in /etc/passwd. Robert Morris and Ken Thompson fixed that in 1974 by introducing crypt(3), which used a modified DES cipher as a one-way function. The password was used as the DES key, and a fixed 64-bit block of zeros was encrypted with it; the result (after 25 rounds of DES) was the stored "hash". A 12-bit salt was added to shift the DES S-boxes, which not only defeated rainbow tables but also prevented attackers from reusing specialised DES hardware to attack multiple users at once. This design was remarkable for its time: it understood that speed was the enemy.

The problem was that DES stayed the same speed while hardware got faster for decades. By the early 1990s, you could crack crypt(3) passwords on a workstation in minutes. The Unix world responded with a sequence of new schemes: FreeBSD-style MD5 crypt ($1$) in 1994, which iterated MD5 1000 times, then Sun's SHA-crypt ($5$ for SHA-256 and $6$ for SHA-512) with configurable iteration counts, then bcrypt ($2a$, later $2y$ and $2b$), then Argon2 ($argon2id$).

The $id$params$salt$hash format that runs through all of these is called the Modular Crypt Format (MCF). It is the reason you can tell at a glance what scheme a hash uses: the prefix $2b$ is bcrypt v2b, $argon2id$ is Argon2id, $6$ is SHA-512-crypt. The format lets a single /etc/shadow file hold hashes from multiple different eras, and it lets libraries like PHP's password_verify pick the right verifier automatically based on the hash prefix. Every modern scheme uses MCF or a close variant, which is why your Django app and your Nginx and your Postgres all coexist happily with different hash algorithms on the same system.

The Adversary Model

To design a password hashing scheme you have to think clearly about what the attacker can do.

The attacker's starting point is usually a stolen database. Perhaps they exploited an SQL injection bug, perhaps they compromised a developer's laptop in a co-working space in Barcelona, perhaps they walked out with a backup tape. However they got it, they now have a copy of the users table with a column containing the output of whatever hash function you chose, plus any salt you stored alongside.

What they do not have is the original plaintext. Their goal is to recover it, and they have two classes of tools at their disposal.

Wordlist attacks take a precomputed list of candidate passwords and hash each one, comparing the output to the stored hashes. Real-world wordlists like rockyou.txt (an old 14-million-entry dump from a 2009 breach) or the more recent hashes.org lists contain most of the passwords any real human has ever picked. Running a wordlist against a database covers the long tail of "password123", "summer2024", "liverpool", and every pet name in Europe in an afternoon.

Brute force generates candidate passwords algorithmically: all 8-character lowercase strings, all 10-character alphanumeric, every pattern the attacker cares to describe. Brute force is exponentially expensive in password length, which is why long passwords are good in principle. But it is linear in hash function speed, which is where the hash choice starts mattering enormously.

The attacker's engine of choice today is a GPU cluster. An Nvidia RTX 4090 running hashcat can compute something like 150 billion MD5 hashes per second. A machine with eight of them does over a trillion per second. Dedicated hashing ASICs for Bitcoin mining can do quadrillions of SHA-256 operations per second, and while they are usually optimised for double-SHA-256 specifically, the underlying hardware can be repurposed for password cracking with enough engineering effort.

So when you pick a hash function, you are implicitly answering a question: how many operations does this attacker have to do to guess one password? The answer determines whether the passwords in your database are cracked in an hour, a year, or never.

Why General-Purpose Hashes Are Wrong

MD5, SHA-1, SHA-256, SHA-3, and Blake2 are all designed to be fast. Fast is good when you are hashing a 10 GiB file to check its integrity, or computing an HMAC on every packet of a TLS stream. Fast is a disaster when you are hashing a password.

Consider SHA-256. A modern CPU core does SHA-256 in about 1 nanosecond per 64-byte block; a dedicated GPU engine does it in a few picoseconds per block. If your password hash is just SHA-256(password), an attacker with a single gaming GPU can test 20 billion candidate passwords per second. For an 8-character password drawn from lowercase letters (26^8 ≈ 2 × 10^11 possibilities), that is 10 seconds of GPU time to enumerate the entire space. For a typical wordlist attack, every recoverable password falls in milliseconds.

The obvious first fix is salting: store a random value alongside each password, and hash password || salt instead of just password. Salting forces the attacker to recompute the entire hash for every user individually, which defeats rainbow tables (precomputed hash-to-password lookup tables). But salt does not slow down the hash function itself. The attacker still runs at 20 billion candidates per second per GPU, per user.

The fundamental insight of password hashing is: you have to slow the function down deliberately. And you have to do it in a way that the attacker cannot cheat around using specialised hardware. That is where password hashing algorithms diverge from general-purpose hash functions and become a field of their own.

PBKDF2: Just Iterate A Lot

The earliest attempt at deliberate slowness is PBKDF2 (Password-Based Key Derivation Function 2), standardised in PKCS #5 in 2000. PBKDF2 works by running HMAC over the password many times in a chain.

PBKDF2(password, salt, iterations) =
    T_1 || T_2 || ... || T_n
 
where T_i = U_1 XOR U_2 XOR ... XOR U_c
      U_1 = HMAC(password, salt || i)
      U_k = HMAC(password, U_{k-1})  for k > 1

The important knob is c, the iteration count. If c is 100,000, PBKDF2 runs HMAC-SHA-256 one hundred thousand times per verification. An honest login becomes something like 100 ms of CPU time, which is fine. An attacker running the same algorithm spends 100 ms per guess per core, which limits a single GPU to a few tens of thousands of guesses per second instead of billions.

PBKDF2 is the default in 1Password, LastPass (before scrypt), Apple's iOS backup encryption, WPA2, and countless enterprise systems. NIST still recommends it in SP 800-63B for regulated environments. For a long time, it was the standard choice for password hashing.

The catch is that PBKDF2 is embarrassingly parallel on GPUs. HMAC-SHA-256 consists of SHA-256 operations with a small per-password state. A GPU can run hundreds of HMAC-SHA-256 chains in parallel across its thousands of cores. The net effect is that a PBKDF2 with 100,000 iterations of SHA-256 is something like 100x slower than plain SHA-256 for a CPU attacker, but the ratio is much worse on GPUs: perhaps 10x slower, not 100x, because the GPU's parallelism defeats the linear chaining. PBKDF2 with SHA-256 is still breakable at billions of guesses per second on a well-provisioned cracking rig.

The industry response was to iterate PBKDF2 harder (1 million iterations, 10 million iterations) and to move to PBKDF2-SHA-512 on 64-bit platforms. Both help, but both push login latency higher. The more fundamental fix had to wait for a different idea.

bcrypt: Memory-Expensive, In A Sense

Niels Provos and David Mazieres published bcrypt in 1999, several years before PBKDF2 became popular. Its design is based on an intentionally expensive key setup in the Blowfish cipher. The core of bcrypt is a routine called EksBlowfishSetup (for "expensive key schedule") which mixes the password and salt into Blowfish's internal state through 2^cost rounds of key schedule updates, where cost is a tunable parameter. The result of that process is used as a key for a final Blowfish encryption of a fixed 192-bit string, which becomes the output hash.

The key feature of bcrypt for our purposes is that its inner loop accesses a 4 KiB S-box table in a data-dependent way. On a CPU with its L1 cache, those accesses are cheap. On a GPU, where the natural way to run many bcrypts in parallel is to keep state in registers or shared memory, maintaining per-password 4 KiB tables for thousands of parallel invocations exhausts the GPU's fast memory and forces everything into slower global memory, which is orders of magnitude slower. The result is that bcrypt resists GPU parallelism much better than PBKDF2 does.

A bcrypt hash looks like this in storage:

$2b$12$LQv3c1yqBWVHxkd0LHAkCO.VePg8c7Ri3h9jp0Ldzf8Zr3p5NJ8Du
  │  │  │                                                   │
  │  │  └── 22-char salt (128 bits base64-encoded)          │
  │  └──── cost = 12 → 2^12 = 4096 rounds                   │
  └─────── algorithm version (2b is current)                 │

                                                      final hash

The cost parameter is an integer exponent. Cost 10 means 1024 rounds. Cost 12 means 4096. Cost 14 means 16,384. Every increment roughly doubles the work. For modern hardware, cost 12 gives a verification time around 250 ms on a decent server CPU, which is a reasonable ceiling for "user waits at login". Cost 14 takes closer to a second, which is painful but sometimes justified for high-value accounts.

bcrypt was the default for Ruby on Rails (via bcrypt-ruby), for Django (until 2014), for OpenBSD's passwd, and for many PHP applications through the password_hash() function. For most of the 2000s and 2010s, if a sensible developer was storing passwords, they were probably storing bcrypt.

bcrypt has two real problems. First, its password length limit: it hashes only the first 72 bytes of input. A user who types a 100-character passphrase gets the same hash as if they typed the first 72 characters. This surprises developers regularly, and has led to some spectacular bugs (including CVE-2024-4340 in Okta and earlier issues in Django's password hasher). Second, its memory usage is fixed: 4 KiB per invocation is an obstacle to GPUs but not to FPGAs or ASICs with enough SRAM. As attacker hardware has improved, the security margin of bcrypt at common cost settings has eroded.

scrypt: Truly Memory-Hard

Colin Percival introduced scrypt in 2009, and with it the concept of a memory-hard function. The idea is explicit: make the algorithm require a large amount of memory to execute, so that attacking it with parallel hardware is bottlenecked not on computation but on memory area.

scrypt works by filling a large array V of N elements with a chain of hashes:

X = PBKDF2(password, salt, 1, 128 * r bytes)
for i = 0 to N-1:
    V[i] = X
    X = BlockMix(X)
for i = 0 to N-1:
    j = integerify(X) mod N
    X = BlockMix(X XOR V[j])
output = PBKDF2(password, X, 1, dkLen)

The first loop fills V sequentially. The second loop reads V at pseudo-random positions determined by the running state. Critically, which positions it reads depends on X, which depends on all previous iterations, which means you cannot precompute which cells are needed: you have to keep them all in memory.

If N = 2^14 and r = 8, the memory footprint is 16 MiB per instance. If N = 2^17, it is 128 MiB per instance. An attacker with a GPU with 24 GiB of memory can run 192 parallel scrypt instances at N = 2^17, not the thousands they could run for bcrypt or the hundreds of thousands for PBKDF2. The memory bandwidth becomes the bottleneck, and dedicated ASICs that try to save area by reducing memory must pay a quadratic cost in time-memory trade-off (Percival proved this formally in the original paper).

scrypt was the basis of the Litecoin cryptocurrency, which used it as a proof-of-work function precisely to resist ASIC mining, with mixed success over time. For password hashing, it is available in OpenSSL, libsodium, Go's golang.org/x/crypto/scrypt, Node's crypto module, and Python's hashlib.

scrypt's main operational challenge is that its parameters interact in non-obvious ways. N is cost, r is block size, p is parallelism. Setting them requires thought: N too low gives weak security, N too high can OOM the server under load if hundreds of users log in at once. A bad scrypt parameter choice can turn "slow to attack" into "our auth service fell over at 3 PM because the cost landed at 512 MiB per login".

Argon2: The Current Consensus

In 2013 the Password Hashing Competition (PHC) was organised by a group of cryptographers to select a modern password hashing algorithm as a successor to bcrypt, scrypt, and PBKDF2. Twenty-four candidates were submitted. After two years of analysis and cryptanalysis, the winner was Argon2, designed by Alex Biryukov, Daniel Dinu, and Dmitry Khovratovich at the University of Luxembourg.

Argon2 comes in three variants:

  • Argon2d: optimised against GPU attacks through data-dependent memory access. Maximum protection against time-memory trade-offs. Potentially vulnerable to side-channel attacks (cache timing) if run on a machine where an attacker can observe memory access patterns. Used for cryptocurrencies and other situations where side channels are not a concern.
  • Argon2i: data-independent memory access, which is safe from cache timing side channels but gives up some resistance to parallel attacks. Intended for password hashing on shared hardware.
  • Argon2id: a hybrid. The first half pass is data-independent (Argon2i-style), the rest is data-dependent (Argon2d-style). Combines cache-timing safety with strong GPU resistance.

Argon2id is the current recommendation for password hashing. OWASP, NIST's draft SP 800-63B revision, the IETF RFC 9106, and essentially every security audit firm in Europe now point at Argon2id as the default.

The algorithm takes three main parameters:

  • m: memory cost in kibibytes.
  • t: time cost, an iteration count over the memory.
  • p: parallelism, how many lanes to split the work across.

A typical Argon2id configuration for an interactive login is m = 64 MiB, t = 3, p = 4, producing a verification time of around 200 ms on a modern server CPU. Scaling m up gives better memory-hardness. Scaling t up gives more iterations. p lets you use multiple CPU cores for the same login, which does not help the attacker because they already use all their cores per candidate.

The Argon2id hash format is self-describing:

$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$0fXFr7wUxCbkHRo9LbE+S9g...
   │       │      │              │            │
   │       │      │              │            └── 32-byte hash
   │       │      │              └────────── 16-byte salt (base64)
   │       │      └────────────── parameters
   │       └──────────────── version 1.3
   └───────────────── variant

Because the parameters are embedded in the hash, you can increase them over time for new passwords without touching the old ones. When a user logs in, the verifier checks the stored parameters against the current policy and, if they are below the policy, rehashes the password with the new parameters and updates the stored hash. This is the standard "rehash on login" pattern, and it is how applications upgrade their hash strength transparently without forcing a mass password reset.

Salts, Peppers, And What They Are For

Salts are per-user random values stored alongside the hash. They are not secret. Their job is to defeat precomputation: without a salt, an attacker could precompute a giant table of hash(candidate) for every plausible password once and then look up every stolen hash in constant time. With a unique per-user salt, the attacker must redo the work for each user, multiplying the cost by the number of users. Salts should be at least 16 bytes of output from a cryptographically secure random generator. Every modern password hashing function (bcrypt, scrypt, Argon2, PBKDF2 as usually deployed) handles salt generation and storage for you.

Peppers are secret values that are used as additional input to the hash, but are stored separately from the database, typically in an application config file, a key management service, or a hardware security module. A pepper does not change the security of an individual hash in isolation; it changes the security of the hash against an attacker who has the database but not the pepper. Without the pepper, every candidate the attacker tries hashes to something that cannot match any stored hash, no matter how close to the real password it is. The attacker has to first steal the pepper separately before their cracking hardware becomes useful.

Peppers are controversial. Some cryptographers argue they are a crutch: if your database and your config files can be compromised independently, then a pepper buys you something, but if they are compromised together (as in an attacker who dumped your entire server), it buys you nothing. Others point out that in practice, databases do get stolen separately from config files and keys, often through backup leaks or SQL injection that do not give filesystem access, and that a pepper is a cheap defence against exactly that class of breach.

A common modern pattern is to HMAC the password with a server-side key stored in a KMS, then feed the HMAC output into Argon2id as the "password" input. The HMAC step is the pepper. An attacker who steals the database has Argon2id hashes of HMAC outputs, not Argon2id hashes of real passwords, and cannot begin cracking without first getting the HMAC key out of the KMS.

func HashPassword(pw string, kmsKey []byte) (string, error) {
    mac := hmac.New(sha256.New, kmsKey)
    mac.Write([]byte(pw))
    peppered := mac.Sum(nil)
    return argon2id.CreateHash(string(peppered), argon2id.Params{
        Memory:      64 * 1024,
        Iterations:  3,
        Parallelism: 4,
        SaltLength:  16,
        KeyLength:   32,
    })
}

Rotating the pepper is harder. If you change the KMS key, every old hash becomes unverifiable. The common approach is to version peppers and keep old versions available for verification until all old hashes have been rehashed on next-login. Some systems double-hash: store both the current-pepper hash and the previous-pepper hash for a migration window.

Timing Attacks And Constant-Time Comparison

You hashed the password. You fetched the stored hash. Now you compare them. And this is the point where a surprisingly large fraction of otherwise-secure password code has historically gone wrong.

// WRONG: vulnerable to timing attack
if computedHash == storedHash { ... }

The equality operator on strings in most languages short-circuits: it compares byte by byte and returns false as soon as it finds a mismatch. On a remote attacker's side, this means that a password whose hash starts with the right byte takes longer to fail than a password whose hash starts with a wrong byte. The difference is tiny (nanoseconds per byte), but it is measurable across a network, and attackers have used it in real attacks to extract secrets from hash comparisons one byte at a time.

The fix is constant-time comparison, a routine that always does the same amount of work regardless of how many bytes match:

import "crypto/subtle"
 
if subtle.ConstantTimeCompare(computedHash, storedHash) == 1 { ... }

Every crypto library worth using provides one. Use it. Always use it. The cost is one extra function call and the benefit is not leaking your hashes byte by byte to anyone who can time your responses.

The same principle applies to the whole login path. If your code early-returns "user not found" in 5 ms but takes 250 ms to return "wrong password" (because it ran bcrypt against a hash), an attacker can enumerate valid usernames by timing the responses. The fix is to run the hash function unconditionally, including for nonexistent users, comparing against a dummy hash. This keeps every login attempt at roughly the same latency regardless of whether the user exists.

Rehashing On Login

The parameters you choose today will be weak in a few years. Moore's law, new hardware, better cracking rigs: by 2030 today's "secure" Argon2id cost of m = 64 MiB will feel quaint. You will want to raise it. But raising it means the old hashes in your database were computed with the old parameters.

The accepted pattern is to rehash on successful login. On every login, after verifying the password against the stored hash:

def login(username, password):
    user = db.get_user(username)
    if not user:
        # constant-time dummy verify to equalise timing
        argon2id_verify(password, DUMMY_HASH)
        raise AuthError()
 
    if not argon2id_verify(password, user.password_hash):
        raise AuthError()
 
    # correct password, check if hash needs upgrading
    if argon2id_needs_rehash(user.password_hash, CURRENT_PARAMS):
        new_hash = argon2id_hash(password, CURRENT_PARAMS)
        db.update_password_hash(user.id, new_hash)
 
    return create_session(user)

This is the only point in the lifecycle where you have access to the plaintext password under the hash policy. Rehashing on login is invisible to the user, spreads the cost of the upgrade across all active users, and leaves inactive users with old hashes forever (which is fine, because inactive users cannot have their sessions stolen and their passwords are only exposed if the database is leaked, in which case the attacker will work harder on the old weaker hashes, which is still bounded by the original cost). Over months, a healthy application's hashes all drift to the current policy naturally.

Real Cracking Rigs And What They Actually Cost

It is worth grounding the theory in concrete numbers. Here are rough hashcat benchmarks as of 2025 on a single Nvidia RTX 4090, running against the specific algorithms we have discussed.

Algorithm Guesses / second
MD5 160 billion
SHA-256 9.5 billion
SHA-512 3.4 billion
PBKDF2-HMAC-SHA-256 (10,000 iter) 950 thousand
PBKDF2-HMAC-SHA-256 (100,000 iter) 95 thousand
bcrypt (cost 12) 28 thousand
scrypt (N=16384, r=8, p=1) 3.5 thousand
Argon2id (64 MiB, t=3, p=4) 180

One 4090 against Argon2id at sensible parameters gets 180 guesses per second. Scale it to eight GPUs in a workstation and you are at 1,440 per second. Run eight of those rigs in a rack and you have under 12,000 guesses per second per rack. A six-character lowercase-letters-and-digits password has about 2^31 possibilities. At 12,000 per second, that is 50 days per user. A proper dictionary attack can still find weak passwords quickly, but random-looking ones are genuinely out of reach of single-rack setups.

For the same money spent on the same hardware cracking MD5-based hashes, you are at 1.3 trillion guesses per second. That six-character password falls in under two milliseconds. The difference between MD5 and Argon2id is the difference between "cracked before your coffee arrives" and "you could leave it running until the next Champions League final and still not finish". Those numbers are the whole point of this article.

Cloud pricing makes the asymmetry even clearer. An AWS p5.48xlarge instance has eight H100 GPUs and costs about 100 euros per hour on-demand in the Frankfurt region. Renting one for 24 hours buys you roughly 3,500 euros of compute. Against Argon2id, that buys a few hundred million guesses in a day. Against SHA-256, the same money buys tens of quadrillions. Picking the right hashing function changes the attacker's budget by five or six orders of magnitude.

Time-Memory Trade-Offs And Why Memory Hardness Matters

The reason memory-hardness is the modern focus is that compute is cheap but memory is expensive. A GPU has thousands of compute cores and a few tens of gigabytes of memory. Dedicated hashing ASICs push the ratio further: an ASIC optimised for SHA-256 has essentially no memory at all, just SHA-256 circuitry shrunk down as tightly as the process allows.

A memory-hard function turns this asymmetry against the attacker. If your password hash needs 64 MiB of memory to compute, an attacker who wants to run 1,024 parallel instances needs 64 GiB of memory, not just more compute. Memory costs the attacker die area, power, routing, and manufacturing complexity. You cannot shrink a 64 MiB buffer by optimising the fabrication process; memory is already dense. You can add more compute cores to a chip cheaply, but memory per chip grows only slowly and is expensive.

Colin Percival formalised this with the notion of a "sequentially memory-hard function": one where reducing the amount of memory below the natural footprint forces a quadratic increase in computation. If an attacker tries to halve the memory by recomputing elements on the fly, the total work quadruples. This makes it impossible to build an ASIC that saves area by skipping memory: the area saved costs compute, and the total cost-time product stays the same.

Argon2 strengthens this further by providing formal memory-hardness proofs for its memory-access patterns, and by letting you tune memory, parallelism, and iterations separately. The best attacker's cost-time product against Argon2id scales linearly with the memory setting. Double the memory budget, double the attacker's hardware cost per guess. That is as close to a "turn the dial" security knob as we have in this field.

What Frameworks Actually Give You

Most developers never call a hashing primitive directly. They call a framework helper and take what it gives them. What they get varies enormously.

Django has used PBKDF2-SHA-256 as the default since 2012, with 870,000 iterations in Django 5.1 (the iteration count is bumped every release). Django also ships Argon2id and bcrypt verifiers and will transparently upgrade older hashes on login if Argon2 is installed. The setting is PASSWORD_HASHERS in settings.py, and migration is as simple as putting Argon2PasswordHasher first in the list.

Ruby on Rails has defaulted to bcrypt through the has_secure_password helper since Rails 3. The cost factor is 12 in production by default, 4 in tests so the suite does not take forever. Upgrading to Argon2 requires the argon2 gem and a manual rehasher.

PHP provides password_hash($pw, PASSWORD_DEFAULT) and password_verify($pw, $hash) in the core. PASSWORD_DEFAULT was bcrypt from PHP 5.5 through 7.x and is still bcrypt in PHP 8.3, but Argon2id is available via PASSWORD_ARGON2ID. Use it. password_needs_rehash() handles the login-time upgrade check automatically.

Node.js does not ship a password hasher in the core crypto module but provides scrypt and PBKDF2 primitives. The dominant third-party libraries are bcrypt (native bcrypt) and argon2 (native Argon2), both published by different maintainers, both stable.

Go has golang.org/x/crypto/bcrypt, golang.org/x/crypto/scrypt, and golang.org/x/crypto/argon2. For Argon2 specifically, the github.com/alexedwards/argon2id wrapper is the closest thing to a "just do the right thing" API.

Rust has the argon2 crate, which implements Argon2d, Argon2i, and Argon2id pure-Rust. The password-hash crate abstracts over algorithms and provides a unified API, which is what you want if you are supporting multiple hashes during a migration.

Python has argon2-cffi for Argon2, bcrypt for bcrypt, and passlib as a unifying layer that supports every legacy scheme you can imagine (including SHA-512-crypt, old MD5-crypt, LanMan hashes, and about fifty others). Passlib is the tool to reach for when you have to migrate a database that accumulated hashes across multiple eras of an application.

The practical takeaway is: in 2026, every mainstream language has a production-ready Argon2id implementation. Use it. If your framework still defaults to something else, override the default or open a pull request.

Operational Gotchas Nobody Tells You

In addition to picking an algorithm and tuning parameters, a working auth system has a long list of operational pitfalls that are easy to miss until production traffic hits.

Memory pressure during login storms. If your Argon2id config uses 64 MiB per verification, ten concurrent logins consume 640 MiB. A hundred concurrent logins, which is normal during a morning login wave for a European SaaS product starting at 09:00 CET, consume 6.4 GiB. If that exceeds the memory headroom of your auth pods, your service crashes or triggers OOMs. The fix is to either limit concurrent logins (a semaphore in your auth handler), scale the auth tier horizontally, or reduce the memory cost with compensating iterations. Argon2's t parameter lets you trade memory for time if the operating constraint is RAM.

CPU pressure during the same storm. Even without memory pressure, Argon2id at sane parameters costs tens of milliseconds of real CPU per login. Under a sustained 500 logins per second, that is 500 * 0.05 = 25 CPU-seconds per wall-clock second, or 25 cores busy just hashing. If your auth pods have 4 cores each, you need 7 pods just for hashing, before any other work. Planning auth capacity means modelling peak concurrent verification count, not average.

Password length limits. bcrypt's 72-byte limit is the famous one, but there are others. scrypt is effectively unbounded in input but has implementation limits in various libraries. Argon2 is bounded by the password parameter's type (usually up to 2^32 bytes, which is fine for any password). If you impose a length limit in your application, make it clear in the error message, and make sure the limit is sensibly larger than what a human would ever type: 128 bytes is a common comfortable choice.

Unicode and normalisation. A user's password may contain Unicode characters. The sequence of bytes depends on the normalisation form (NFC vs NFD), and different platforms produce different bytes for the same visible character. A user who registered from a Mac in Lisbon using NFD may not be able to log in from a Windows laptop using NFC. The fix is to normalise the password to NFC (or NFKC) before hashing, both on signup and on verify. libsodium, argon2-cffi, and PHP's password_hash do not normalise for you; the application must.

Case sensitivity. Passwords should always be case-sensitive. Some legacy systems historically lowercased passwords or truncated them, and a few old banks still do. Do not inherit that bug. If migrating from such a system, issue a mandatory reset at first login.

Preauth dummy verification. We touched on this earlier: for nonexistent users, run the hash function against a fixed dummy hash so that timing is indistinguishable. Pre-generate the dummy hash at startup using the current policy and refresh it when the policy changes. Make sure the dummy hash cannot accidentally be a real user's hash (it won't be, but be deliberate).

Silent hash upgrades logged as policy violations. If you rehash on login and log "hash upgraded" as an event, make sure the event is not classified as a security policy violation by your SIEM. A customer success manager in Prague having her hash quietly upgraded from bcrypt cost 10 to Argon2id should not trigger a pager incident. Tag the event appropriately at source.

Handling Breaches

Sooner or later, every successful application has a close call or a real breach. If you are lucky, you discover it before the attacker does. If you are unlucky, you discover it when a researcher or a journalist tells you. Here is what a reasonable response plan involves, from the password perspective.

Rotate the pepper immediately. If you have a pepper, changing the KMS key invalidates every existing hash from the attacker's point of view (they would need both the database and the new pepper to continue cracking). This is the cheapest emergency measure if you built it in.

Force password reset on every account. Yes, every account. Even the ones that seem fine. Send email-verified reset links, not in-band resets. Assume the attacker is trying to beat you to the reset. Users hate this but it is the only way to regain certainty.

Contact account takeover monitoring services. HaveIBeenPwned, Shodan's breach feed, and similar services will eventually see the leaked data if it circulates. Proactive notification is better than reactive scrambling.

Audit what else the attacker could have used the passwords for. Credential stuffing is the dominant threat to users after a breach: attackers try the leaked credentials against other sites where the user may have reused the password. Notifying your users with enough urgency to actually change their passwords elsewhere is both the right thing to do and, increasingly, a regulatory requirement under GDPR and the NIS2 Directive in Europe.

Write a public postmortem. It will hurt. It will also earn back a material fraction of the trust you have just lost.

Attacker Strategies In Detail

The field of password cracking has evolved into a small industry of techniques that go far beyond naive brute force. Understanding them helps you appreciate why the defender's job is hard.

Dictionary attacks are the simplest: pick a list of candidate passwords, hash each one, check. The rockyou.txt wordlist from the 2009 RockYou breach has 14 million entries and still cracks an embarrassing fraction of real passwords every time a new breach is dumped. More modern lists from hashes.org and weakpass.com combine every historical breach into one pool of hundreds of millions of candidates. A defender who tolerates any of those passwords is already lost against even a lazy attacker.

Rule-based attacks extend a dictionary with transformations. Take every word in the list and try it in uppercase, with a "1" or "!" appended, with common leet-speak substitutions (a → @, o → 0, e → 3), with years appended, with common prefixes. Hashcat's rule engine is a small domain-specific language for exactly this, and its standard rules file (dive.rule, OneRuleToRuleThemAll.rule) turns a 14-million-word list into effectively billions of variants. A dictionary of 100,000 base words combined with 10,000 rules tests a trillion candidates, but it is structured in a way that matches what humans actually do when they "add complexity" to a remembered password.

Markov-model attacks go further. Train a language model on a corpus of known leaked passwords, then sample from it to generate candidates in order of likelihood. The OMEN generator (Ordered Markov Enumerator) and similar tools produce human-plausible strings that no dictionary contains but that humans still pick. A Markov attack can crack passwords that are not in any wordlist, as long as they follow patterns humans naturally gravitate toward.

Mask attacks encode the attacker's knowledge of password structure. If you know a user's company requires at least one uppercase, one digit, and one special character, you can try all 8-character strings matching ?u?l?l?l?l?l?d?s and skip the rest. Mask attacks are how most corporate password policies get defeated: the policy constrains the space so tightly that the attacker's search becomes efficient.

Credential stuffing is the attack against users rather than against a single hash. Attackers take a dump of cleartext passwords from one breach and try them on other services. A user who reused "Summer2024!" across their bank and their streaming service is vulnerable to any breach of either one. This is why HaveIBeenPwned's password list is important, and why NIST's SP 800-63B specifically recommends rejecting passwords that appear in public breach corpora at signup time. Stopping a user from choosing a password that is already in the attacker's rockyou list is worth more than any amount of hash-function tuning.

Knowing these attacks changes how you think about your defences. A hash function's slowness buys you time, but it does not buy you immunity. If a user's password is in a dictionary, it will fall regardless of how much you iterate the hash. Your job as an application is to force the attacker to do the hardest version of the attack, which means combining slow hashing with password policies that reject obvious garbage.

Hardware-Backed Hashing For The Paranoid

Large enterprises and banks in Luxembourg, Milan, and Amsterdam often take the pepper idea further by pushing the hash itself into a Hardware Security Module (HSM). The pattern is: the HSM holds a secret key that is never exportable, and provides an operation that takes a password, does a keyed operation on it, and returns a value that the application stores. Without the HSM, the stored value is cryptographic noise: there is no way to even begin guessing passwords, because every guess would require a call to an HSM the attacker does not have.

This defends against a total database theft much more strongly than a software pepper does, because HSMs are designed with tamper-resistant hardware, rate-limiting, and often hardware-enforced policy. An attacker who steals a database backup tape from a data centre in Frankfurt has no way to crack anything: the keyed operation is bound to a specific physical module that stayed behind. Even physically stealing the HSM is hard, because enterprise HSMs zero themselves on tamper detection.

The downside is latency and availability. Every login requires a round trip to the HSM, and if the HSM cluster is down, nobody can log in. The cost is also non-trivial: a CloudHSM instance or an on-prem Thales module is several thousand euros per year, per module, and you typically want two for redundancy. This is why HSM-backed password hashing is a pattern you see at banks, payment processors, and anywhere compliance regulations make it mandatory, but not at typical web applications.

Cloud services have started to blur the line. AWS KMS's HMAC keys, Azure Key Vault's "managed HSM", and Google Cloud KMS's "Cloud HSM" all let you do keyed operations server-side without owning the hardware. The "pepper in a KMS" pattern we showed earlier is the budget version of HSM-backed hashing. For most applications it is a sensible middle ground: stronger than a file-based secret, weaker than a dedicated HSM, with cloud-provider uptime SLAs and low per-operation cost.

WebAuthn, Passkeys, And The Beginning Of The End

The best password hash is no password at all. WebAuthn, the web standard for public-key authentication, is now supported in every major browser, every mobile OS, and every password manager. When a user registers with a WebAuthn credential (commonly called a "passkey" in consumer-facing language), their device generates a public/private keypair for your application. The public key is sent to your server and stored. The private key never leaves the device. On login, the server sends a challenge, the device signs it with the private key, and the server verifies with the public key.

There is no hash to crack. The server's database contains only public keys, which are useless to an attacker on their own. Phishing resistance is baked in, because the browser verifies the origin before signing: a user on accounts.forged-bank.example will not unlock the credential bound to accounts.real-bank.example. Credential stuffing is impossible because every relying party has a different keypair.

Passkeys have been rolling out steadily since Apple, Google, and Microsoft jointly announced support in 2022. By 2025, every major consumer service in Europe (banks, government eID systems under eIDAS 2.0, most of the top hundred websites) supports them as a primary or fallback option. The eIDAS 2.0 European Digital Identity Wallet mandates WebAuthn-compatible authentication for cross-border public services.

Does this mean password hashing is obsolete? Not yet. Users still have passwords. Password managers still have master passwords (usually protected by Argon2id or scrypt). Legacy systems still require passwords. Recovery flows still depend on emails that are themselves protected by passwords. The transition is happening, but it will take many years, and in the meantime storing passwords correctly remains the price of admission to "the user is not obviously at risk".

If you are building a greenfield application today, the right design is: offer WebAuthn as the primary credential, support magic-link email login as a fallback for users who cannot use WebAuthn, and only fall back to passwords when nothing else is possible. And when you do store a password, use Argon2id. That is the 2026 consensus.

Quick Reference For Picking An Algorithm

If you are writing new code today, pick Argon2id with parameters roughly:

  • m = 65,536 (64 MiB)
  • t = 3
  • p = 4
  • salt length = 16 bytes
  • hash length = 32 bytes

If Argon2 is not available in your language or framework, use scrypt with N = 32,768, r = 8, p = 1 (about 32 MiB per invocation), or bcrypt with cost = 12. Do not use PBKDF2 unless you are in a regulated environment that requires FIPS-approved primitives, in which case use PBKDF2-HMAC-SHA-512 with at least 600,000 iterations as of 2025, rising to 1.5 million by 2028 as hardware improves.

Do not use any general-purpose hash function (MD5, SHA-1, SHA-2, SHA-3, Blake2, Blake3) as a password hash. They are too fast. "But I salt and pepper it" does not help against an attacker with a GPU: they still run billions of candidates per second through your hash function. You need a slow, memory-hard function, not a fast one.

Do not roll your own. Modern password hashing is a field where the primitives have been attacked by professional cryptanalysts for years, and the edge cases matter. Use a well-maintained library (libsodium, passlib, bcrypt, argon2-cffi, bcrypt.js, PHP's password_hash). Do not implement it yourself.

Do not store hashes in a column that allows plaintext comparisons. Store the full self-describing hash string including the algorithm identifier, parameters, and salt. Reject any hash whose algorithm or parameters are below your current policy and trigger a rehash next login.

Never, ever log passwords, not even temporarily, not even in debug mode. Scrub them from exception traces, from request logs, from crash dumps. An astonishing number of breaches turn a database compromise into a plaintext password dump because the plaintext was sitting in the logs.

The Real Question

Every password hashing decision is ultimately an answer to one question: how expensive do you want the attacker's cracking job to be? The knobs you turn (algorithm, cost, memory, iterations, salt, pepper, timing) are all levers on that single number.

A good modern configuration, implemented correctly, can force an attacker with a top-tier GPU cluster to spend seconds per candidate. Seconds per candidate times 2^40 possible real passwords is tens of thousands of years. That is the number you want. If your choice gives the attacker microseconds per candidate, the same 2^40 space falls in days.

Password hashing is less about secrecy and more about budget. Store a hash that makes cracking expensive enough that it is never worth anyone's time to do. Revisit the budget every few years, because hardware gets cheaper. Assume your database will eventually leak, and make sure the numbers in the attacker's budget spreadsheet never add up.

Do that, and your users are safe. Do anything less, and the next breach headline will be yours.

A final note on culture. Every developer eventually encounters a legacy system where the passwords are hashed with SHA-1 or MD5, or not hashed at all, and proposes to migrate. The migration is straightforward on paper: rehash on login, prioritise active accounts, rotate the rest behind mandatory reset over six months. What is harder is the organisational conversation. There will be someone, usually a manager who does not understand the attacker model, who asks "why are we spending engineering time on something the customers cannot see". The answer is that security is infrastructure: invisible when it works, catastrophic when it fails. A small investment now, measured in weeks of engineering and a few kilobytes of per-user state, defers indefinitely an incident that would cost months of recovery, a regulatory fine under GDPR, a class action somewhere in the EU, and a public apology. The cost-benefit ratio is one of the best in all of software engineering.

The fact that you are reading this article means you take that ratio seriously. Keep going. Pick Argon2id, tune it for your hardware, pepper it through a KMS, compare in constant time, rehash on login, and sleep better.