Why SHA-256 Is the Wrong Way to Hash a Password
SHA-256 is fast, and speed is the attacker advantage. What to use instead, the OWASP parameters, and the bcrypt limit that truncates.
Table of contents
SHA-256 is a good hash function. It is collision-resistant, standardised, implemented everywhere, and audited to death. It is also the wrong tool for storing a password, and the reason is the property that makes it good everywhere else: it is fast.
A password hash needs to be slow on purpose. Every millisecond you add costs a legitimate login almost nothing and costs an attacker running an offline crack the same millisecond multiplied by billions.
Key takeaways#
- General-purpose hashes are designed for throughput. Password hashes are designed against it.
- Use Argon2id for new systems. OWASP's minimum is 19 MiB of memory, 2 iterations, 1 degree of parallelism.
- A salt stops precomputation. It does not slow anyone down. These are separate problems needing separate fixes.
- bcrypt silently truncates at 72 bytes, and bytes are not characters.
- Adding rounds of SHA-256 yourself is not a substitute. Memory hardness is the part you cannot hand-roll.
What makes a hash unsuitable for passwords?#
Speed, and nothing else. SHA-256 has no cryptographic weakness relevant here. The problem is that it was built to hash large volumes of data quickly, and it succeeds.
An attacker who steals your database is not trying to reverse the hash. Reversing is infeasible. They guess: take a candidate password, hash it, compare, repeat. The only thing standing between a stolen hash and a recovered password is how many guesses per second the attacker can make.
SHA-256 is trivially parallel, which is precisely what GPU hardware is built for. A commodity graphics card runs an enormous number of SHA-256 operations per second, and cracking rigs run several cards. Against a fast hash, any password from a wordlist falls almost immediately, and the exhaustive space for shorter passwords is not far behind.
Password hashing functions invert this. They are deliberately expensive, and the modern ones are expensive in memory as well as time, because memory is the resource GPUs and ASICs cannot cheaply multiply.
Does a salt fix it?#
No, and this is the most common misunderstanding in the area.
A salt is a unique random value stored alongside each hash. Its job is to make every stored hash different even when two users choose the same password. That defeats precomputation: an attacker cannot build one rainbow table and use it against your whole database, and cannot spot that 400 users share a password by seeing 400 identical hashes.
What a salt does not do is make any individual guess slower. Against a salted SHA-256, the attacker targets one account at a time and still gets their full guess rate. Salting is necessary and it is not sufficient. Salted SHA-256 is still SHA-256.
Modern password hashing functions generate and embed the salt for you, which is one fewer thing to get wrong.
What should I use for password hashing instead?#
The OWASP Password Storage Cheat Sheet gives a clear order of preference.
| Algorithm | When to use it | OWASP minimum |
|---|---|---|
| Argon2id | New systems, first choice | 19 MiB memory, 2 iterations, 1 parallelism |
| scrypt | When Argon2id is unavailable | N=2^17, r=8, p=1 |
| bcrypt | Legacy systems and existing deployments | work factor ≥ 10 |
| PBKDF2 | When FIPS-140 compliance is required | 600,000 iterations with HMAC-SHA-256 |
Argon2id is the default recommendation because it is memory-hard: it requires a configured amount of RAM to compute, so an attacker cannot trade memory for parallelism. A GPU with thousands of cores and limited memory per core cannot run thousands of Argon2id instances at once, which is exactly the advantage the design removes.
Those minimums are floors, not targets. The usual tuning method is to raise the parameters until a single hash takes as long as your login endpoint can tolerate — a few hundred milliseconds is a common landing point — and then keep that number under review as hardware improves.
Note the last row. PBKDF2 appears in the table because FIPS-140 environments require an approved primitive, not because it is a good choice on the merits. It is not memory-hard, which is why its iteration count has to be so high.
The bcrypt limit that catches people#
bcrypt has a hard input limit of 72 bytes, inherited from Blowfish's key schedule. Anything past byte 72 is discarded, usually silently.
Two consequences follow, and both are worse than they sound.
The limit is measured in bytes, not characters. A 40-character passphrase sits comfortably inside it in ASCII and can exceed it once it contains emoji, Cyrillic or CJK, where one character occupies two to four bytes. Users with non-Latin passwords hit this earlier than anyone tests for.
Truncation also creates collisions. Two passwords identical for their first 72 bytes produce the same hash, so the longer one authenticates with the shorter one. A user who carefully appends a long suffix gains nothing from it.
If you are staying on bcrypt, validate byte length rather than character length and reject anything above 72. Argon2id has no comparable limit.
Is stretching SHA-256 myself good enough?#
Running SHA-256 in a loop a hundred thousand times does make guessing more expensive, and PBKDF2 is essentially a rigorous version of that idea. Hand-rolling it is still a poor plan.
You only get time hardness. Your loop is as GPU-friendly as a single SHA-256 call, just longer, so the attacker's hardware advantage is unchanged and you have bought a linear slowdown against an opponent with a parallel one. Memory hardness removes that advantage, and there is no way to bolt it on.
The details are also easy to get wrong in ways testing will not show. Comparisons need to be constant-time to avoid timing leaks, and the parameters need a migration path for when they are raised. A vetted library has answers to both.
Migrating away from a fast hash#
You cannot recompute the correct hashes, because you do not have the plaintext. The workable approach upgrades each user at their next login:
- Store the algorithm and parameters alongside every hash, if you do not already.
- On successful login, you hold the plaintext for one request. Re-hash with Argon2id and replace the record.
- Track what fraction of accounts have migrated.
- For accounts that never return, force a reset after a deadline rather than leaving weak hashes indefinitely.
The wrapping alternative — storing argon2(sha256(password)) — migrates everyone immediately without waiting for a login, at the cost of permanently carrying a two-layer scheme that every future maintainer has to understand. It is a legitimate choice when the old hashes are unsalted MD5 and the risk of leaving them is high.
For generating checksums, verifying file integrity or producing a content-addressed id, SHA-256 remains the right answer, and the hash generator covers those cases. When you need a strong password rather than a hash of one, the password generator draws from the browser's cryptographic random source rather than Math.random.
The short version#
SHA-256 is not broken and it is not a password hash. Its speed is a feature everywhere except here, where it is the whole vulnerability. Use Argon2id, tune it until a hash costs a few hundred milliseconds, let the library handle the salt, and if you are on bcrypt, check your input length in bytes before something truncates it for you. And check where the salt and any tokens come from, because Math.random is predictable.
Sources: OWASP Password Storage Cheat Sheet
Tags
- Security
- Passwords
- Hashing
- Argon2
- Bcrypt
