Password Hashing: bcrypt vs Argon2id, and Why Fast Is a Bug
A password hash is the one function in your codebase you want to be slow. One RTX 4090 runs MD5 at 164 billion guesses a second and bcrypt at 184 thousand. Here are the parameters OWASP, RFC 9106 and NIST actually publish, and why memory cost is the setting that hurts an attacker most.

Key takeaways
- On one stock RTX 4090, hashcat v6.2.6 benchmarks MD5 at 164.1 GH/s and SHA2-256 at 21,975.5 MH/s, while bcrypt at cost factor 5 runs at 184.0 kH/s and scrypt at 16 MiB runs at 7,126 H/s, a spread of roughly 892,000x between MD5 and bcrypt on identical hardware.
- The current OWASP Password Storage Cheat Sheet recommends Argon2id at m=19456 (19 MiB), t=2, p=1 as a minimum, with m=47104 (46 MiB), t=1, p=1 also listed; bcrypt with a work factor of at least 10 and a 72-byte password limit; scrypt at N=2^17, r=8, p=1; and PBKDF2-HMAC-SHA256 at 600,000 iterations if FIPS-140 compliance is required.
- RFC 9106 names Argon2id with t=1, p=4 lanes and m=2^21 (2 GiB) as the FIRST RECOMMENDED option, and states that for Argon2d and Argon2id a single pass maximizes the attacker cost for a fixed amount of defender time, which is why memory, not iteration count, is the parameter that matters.
- bcrypt has been partly memory-hard since 1999: its authors wrote that the eksblowfish S-boxes require 4 KB of constantly accessed and modified memory that cannot be shared across simultaneous executions. Argon2id at OWASP minimum settings asks for 19 MiB per guess, roughly 4,800 times more state per parallel attempt.
- NIST SP 800-63B requires that passwords be salted and hashed with a salt of at least 32 bits, and additionally recommends a keyed hashing or encryption step whose secret key is stored separately from the database, ideally inside a hardware security module or trusted execution environment.
Almost every function you write, you want to be fast. Password hashing is the one exception, and it is the exception that keeps showing up in breach headlines. If a stolen database turns out to be MD5, or SHA-256, or SHA-256 with a salt bolted on, the passwords are effectively already cracked. Not theoretically. Over a weekend, on a gaming GPU somebody already owns.
Here is the number that made it click for me. A single stock RTX 4090, benchmarked with hashcat v6.2.6, runs MD5 at 164.1 billion guesses per second.[5] The entire space of eight-character lowercase-and-digit passwords is about 2.82 trillion candidates. At that rate, one card walks the whole space in about seventeen seconds. Against SHA-256 on the same card, a bit over two minutes.
Against bcrypt on that same card, at a cost factor that is already considered too low, the attacker gets 184,000 guesses per second instead.[5] Same silicon, same password, roughly 892,000 times slower. That single ratio is the whole discipline.
Takeaway
Every one of these is the same card running the same brute force. The only thing that changed is the function. A password hash is a business decision about how much an attacker has to spend, and you make that decision when you pick the algorithm, not later.
A salt is not a speed bump
People reach for a salt as though it fixes the speed problem. It does not. A salt is a unique value stored next to each hash, and it buys you exactly two things. It stops precomputation, so an attacker cannot look your hash up in a rainbow table built years ago. And it makes two users who both picked hunter2 produce two different hashes, so cracking one does not crack the other.[1]
That is real value, and it is not optional. NIST SP 800-63B says passwords SHALL be salted and hashed, that the salt SHALL be at least 32 bits, and that both the salt and the resulting hash SHALL be stored for each password.[3] But notice what a salt does not do. It costs the attacker nothing per guess. Salted MD5 is still 164 billion guesses a second. It just means they have to point the GPU at one account at a time.
A pepper is the other thing, and it is worth knowing the difference. A pepper is one secret value shared across every stored password, and the whole point is that it lives somewhere other than the database.[1] NIST puts it this way: verifiers SHOULD perform an additional iteration of a keyed hashing or encryption operation, using a secret key known only to the verifier. That key is stored separately, ideally inside a hardware security module or a trusted execution environment.[3] So a leaked database dump on its own is useless. The attacker needs the app server too.
Heads up
Three knobs, and only one of them really hurts
Modern password hashing functions expose up to three parameters. Time cost, usually written t, is how many passes the function makes. Memory cost, m, is how much RAM one hash computation has to hold. Parallelism, p, is how many lanes run at once.[2] bcrypt and PBKDF2 give you the first one only. scrypt and Argon2 give you all three.
Turning up time cost is the intuitive move, and it is the weaker one. Doubling the passes doubles the attacker's work, sure. It also doubles yours, and your login endpoint is the thing that has to stay responsive under a credential-stuffing flood. OWASP's general rule is that computing a hash should take under a second, and warns that a work factor set too high is itself a denial-of-service surface.[1]
Memory cost is different in kind, and this is the part I think most write-ups undersell. A GPU has thousands of cores and a fixed pool of RAM. Cores are cheap and abundant; memory per core is neither. So making a hash need a lot of memory does not slow down one guess so much as it caps how many guesses can happen at the same time, on hardware whose entire advantage is doing things at the same time.
“Time cost makes an attacker wait. Memory cost decides how many attackers can fit on the card. Only one of those scales against custom hardware.”
Do the arithmetic on a 24 GiB card. bcrypt's working state is 4 KB per guess,[4] so about 6.3 million concurrent attempts fit in that memory. Argon2id at the OWASP minimum of 19 MiB per guess fits roughly 1,290. That is a factor of about 4,800 in parallelism, before a single hash is computed. And unlike time cost, it is not something the attacker can buy their way out of cheaply, because the expensive thing on a custom ASIC is exactly the thing you just demanded more of.
The Argon2 authors say this out loud in the RFC, and it is the single most useful sentence in the document: for Argon2d and Argon2id, one pass maximizes the attack costs for a fixed amount of defender time.[2] Read that again. Given a budget of half a second on your server, you are better off spending it on memory than on passes. The knob everyone instinctively turns is the wrong knob.
The four candidates, and what to actually set
How we got here
Four password hashing functions, in the order they arrived
- 19994 KB of state
bcrypt, at USENIX
Niels Provos and David Mazieres publish "A Future-Adaptable Password Scheme". Their design note is the interesting part: the eksblowfish S-boxes require 4 KB of constantly accessed and modified memory, and cannot be shared across simultaneous executions, which they say vastly limits attempts to pipeline the network in hardware. Memory hardness, before anyone called it that.
- September 2000
PBKDF2 lands in RFC 2898
PKCS #5 version 2.0 specifies a key derivation function whose only cost parameter is an iteration count. It has one enduring advantage and it is not cryptographic: it is what standards bodies approved, so FIPS-validated implementations exist.
- May 2009
scrypt makes memory the point
Colin Percival presents "Stronger Key Derivation via Sequential Memory-Hard Functions" at BSDCan. The specification is later published as RFC 7914 in August 2016.
- 2013 to 201524 candidates, 1 winner
The Password Hashing Competition picks Argon2
An open competition run in the style of the NIST AES and SHA-3 processes. Argon2, by Alex Biryukov, Daniel Dinu and Dmitry Khovratovich of the University of Luxembourg, wins.
- September 2021
RFC 9106 writes the parameters down
The IRTF publishes Argon2 as an Informational RFC. Argon2id MUST be supported by any implementation; Argon2d and Argon2i MAY be. The first recommended option is t=1, p=4 lanes, m=2 GiB, a 128-bit salt and a 256-bit tag.
Takeaway
Twenty-two years separate bcrypt from RFC 9106, and the direction of travel is one-way: from making the CPU work harder to making the attacker's memory the bottleneck. Nothing since 2015 has changed that answer.
So what do you set? These are the current OWASP Password Storage Cheat Sheet values, quoted rather than remembered, because this is the exact place where a half-recalled number becomes a real weakness.[1]
- Argon2id, the default for new systems. Minimum m=19456 (19 MiB), t=2, p=1. OWASP lists five configurations it treats as equivalent, trading memory for passes: m=47104 (46 MiB) at t=1, m=19456 (19 MiB) at t=2, m=12288 (12 MiB) at t=3, m=9216 (9 MiB) at t=4, m=7168 (7 MiB) at t=5, all at p=1. RFC 9106 aims much higher where you can afford it, at t=1, p=4, m=2 GiB.[2]
- scrypt, if Argon2id is not available. N=2^17 (128 MiB), r=8, p=1, with four lower-memory alternatives down to N=2^13 (8 MiB) at p=10.
- bcrypt, for legacy systems. Work factor of 10 or more, as large as your verification server can stand, and enforce a maximum password length of 72 bytes.
- PBKDF2, only if FIPS-140 compliance requires it. 600,000 iterations with HMAC-SHA-256, or 220,000 with HMAC-SHA-512. PBKDF2-HMAC-SHA1 needs 1,400,000 and is legacy only.
Receipt
bcrypt(base64(hmac-sha384(password, pepper))), with the pepper kept out of the database.Never write your own, and verify in constant time
Two rules that sound like folklore and are not. The first: use a library that implements the whole scheme, salt generation included. Every algorithm above requires the caller to provide a salt, and the standard implementations generate one for you with a cryptographic random source and encode it into the output string.[1] Hand-rolling salt generation with random() is a real, repeat-offender bug.
The second: never compare hashes with ==. A normal string comparison returns as soon as it finds a differing byte, so how long it takes leaks how many leading bytes matched, one guess at a time. Use the library's own verify function, which compares in constant time. This is the same class of mistake as comparing API tokens or webhook signatures byte by byte, which is one of the recurring findings in the OWASP API security failures that keep shipping.
Which brings up the third thing, the one that actually gets skipped: migration. Your parameters will be too weak in five years. That is not a prediction, it is the design. OWASP's answer is to rehash on successful login, because that is the one moment the plaintext exists on your server.[1] NIST asks you to store a reference to the scheme and its cost factor with each password precisely so this is possible.[3]
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError, InvalidHashError
# OWASP minimum: m=19456 KiB (19 MiB), t=2, p=1.
# Tune upward until one hash costs about 0.5s on your login server.
ph = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1)
def login(user, password: str) -> bool:
try:
# Constant-time comparison happens inside verify(). Never use ==.
ph.verify(user.password_hash, password)
except (VerificationError, InvalidHashError):
return False
# The only moment the plaintext exists on this server, so it is the
# only moment we can upgrade a hash without asking the user to reset.
if ph.check_needs_rehash(user.password_hash):
user.password_hash = ph.hash(password)
db.save(user)
return TrueComing from a bcrypt table, the same shape works with one branch: if the stored value starts with $2b$, verify it with the bcrypt library, and on success write back an Argon2id hash. No mass reset, no maintenance window, no user ever notices. The catch OWASP names is that this only reaches users who come back, so set a deadline and expire the stragglers rather than carrying MD5 rows for another decade.[1]
Side note
What I would actually do on Monday
Argon2id, m=19456, t=2, p=1, tuned upward until a single hash costs about half a second on your real login server. A per-password salt from the library, never from you. A pepper only if you have somewhere real to keep the key and a plan for rotating it. Verify through the library's verify function. Rehash on login, and store the parameters with the hash so future-you can tell which rows are stale.
And keep the hashing decision separate from the session decision. What the server hands back after a successful login is its own problem with its own tradeoffs, which I worked through in the comparison of JWTs against server-side sessions. While you are in there, note that hash agility, the ability to swap an algorithm without a migration project, is the same muscle you will need for the post-quantum cryptography migration. Build it once.
The uncomfortable part of all this is how cheap it is. Argon2id is one dependency and one line of configuration. There is no engineering reason left to be storing a fast hash in 2026, and the next breach writeup that says otherwise will not be a story about cryptography. It will be a story about a table nobody looked at.
Sources and further reading
- 1.PrimaryOWASP Password Storage Cheat Sheet. Source for every parameter recommendation quoted here: Argon2id, scrypt, bcrypt and PBKDF2 settings, the 72-byte bcrypt limit, peppering, and upgrading hashes on login.
- 2.PrimaryRFC 9106: Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications. IRTF, September 2021. Parameter choice in section 4, and the finding in section 7.3 that one pass maximizes attack cost for fixed defender time.
- 3.PrimaryNIST SP 800-63B, Digital Identity Guidelines: Authentication and Authenticator Management. Salting and hashing requirements, the 32-bit minimum salt, storing the scheme and cost factor for migration, and the keyed hashing recommendation.
- 4.PrimaryNiels Provos and David Mazieres, "A Future-Adaptable Password Scheme", USENIX 1999. The bcrypt paper, including the note that the eksblowfish S-boxes require 4 KB of constantly accessed and modified memory per simultaneous execution.
- 5.Datahashcat v6.2.6 benchmark on the NVIDIA RTX 4090, stock clocks. MD5 at 164.1 GH/s, SHA2-256 at 21,975.5 MH/s, bcrypt mode 3200 at cost factor 5 at 184.0 kH/s, scrypt mode 8900 at 7,126 H/s.
Frequently asked questions
- Why is SHA-256 a bad choice for hashing passwords?
- Because SHA-256 is designed to be fast, and speed is exactly what an attacker with a stolen password database needs. A single stock RTX 4090 benchmarks at 21,975.5 MH/s on SHA2-256 and 164.1 GH/s on MD5, so the entire space of eight-character lowercase-and-digit passwords, about 2.82 trillion candidates, falls in roughly two minutes against SHA-256 and about 17 seconds against MD5. Password hashing functions such as Argon2id and bcrypt are deliberately slow and, in Argon2id's case, deliberately memory-hungry.
- What are the current OWASP recommended Argon2id parameters?
- OWASP recommends Argon2id with a minimum of m=19456 (19 MiB) of memory, t=2 iterations and p=1 degree of parallelism. The cheat sheet lists five equivalent-strength configurations that trade memory against passes: m=47104 (46 MiB) with t=1, m=19456 (19 MiB) with t=2, m=12288 (12 MiB) with t=3, m=9216 (9 MiB) with t=4, and m=7168 (7 MiB) with t=5, all at p=1. RFC 9106 aims higher for environments that can afford it, naming Argon2id at t=1, p=4 and m=2 GiB as its first recommended option.
- What does a salt protect against, and what does a pepper protect against?
- A salt is a unique per-password value that stops precomputation and stops identical passwords producing identical hashes, so an attacker cannot use a rainbow table or crack two accounts with one guess. It is not secret and it does not slow anything down. A pepper is a single secret value shared across all stored passwords and kept outside the database, so a dump of the password table alone is not enough to start guessing. NIST SP 800-63B requires salts of at least 32 bits and recommends the pepper step be done with a key held in a hardware security module.
- Is bcrypt still safe to use in 2026?
- Yes, at a work factor of 10 or higher and with passwords capped at 72 bytes, though Argon2id is the better default for new systems. OWASP still lists bcrypt as acceptable for legacy systems and bcrypt holds up far better than any fast hash: the same GPU that does 164.1 GH/s on MD5 manages only 184.0 kH/s on bcrypt at cost factor 5. The gap between bcrypt and Argon2id is memory. bcrypt needs 4 KB of state per guess, Argon2id at OWASP minimums needs 19 MiB, which is what limits how many guesses a GPU or ASIC can run at once.
- How do you migrate an existing user table to a stronger password hash?
- Rehash on successful login: when a user authenticates, verify the password against the stored hash with the old algorithm, then, because the plaintext is in memory for that one moment, recompute the hash with the new algorithm and parameters and save it. OWASP describes exactly this approach and notes it works only for users who come back, so plan to expire or reset the stragglers rather than leaving weak hashes in the table forever. Storing the algorithm and cost factor alongside each hash, as the PHC string format does, is what makes the check possible.
Written by
Tech Talk News Editorial
Computer engineering background. Writes about software, AI, markets, and real estate, and the places where the three meet.
More about the author