Never Store Passwords With SHA-256 — Here's the Math
The claim, stated bluntly
If your user table has a password_hash column filled by a single call to SHA-256, you have not hashed passwords. You have lightly obscured them. The difference matters the moment an attacker walks away with a copy of your database, and it is measured not in opinions but in hashes per second.
I learned this the embarrassing way. Years ago I inherited a small internal admin panel — maybe 4,000 user accounts — that stored credentials as sha256(password). No salt, no iteration, just a clean 64-character hex string per row. It looked tidy. It looked cryptographic. During a routine audit I exported a few hundred of those hashes into a wordlist cracker just to prove a point to myself, expecting it to grind for a while. It did not grind. Roughly 60% of them fell over inside the first two minutes, the obvious ones (summer2019, Password1!, the company name plus a year) appearing almost instantly. That two minutes rearranged how I think about storage forever.
The reason is not that SHA-256 is broken. SHA-256 is an excellent hash function. The problem is that it is excellent at exactly the wrong thing for this job: it is fast.
The math, with real numbers
A password hash's only defensive job, after a breach, is to make guessing expensive. Every guess costs the attacker some amount of compute, and your security budget is "how many guesses per second can one machine make against this hash."
For SHA-256, that number is catastrophic. A single consumer RTX 4090 running hashcat (mode 1400) benchmarks at roughly 21,975 MH/s — call it 22 billion SHA-256 hashes per second on the public v6.2.6 figures. One card. Not a cluster, not a state actor's basement, a gaming GPU you can buy retail.
Let me make that concrete. Suppose a password is drawn from the 95 printable ASCII characters. The size of the keyspace is 95^length:
length 6 -> 95^6 ≈ 7.35 × 10^11 guesses
length 7 -> 95^7 ≈ 6.98 × 10^13 guesses
length 8 -> 95^8 ≈ 6.63 × 10^15 guesses
Divide by 22 billion/s:
- 6 chars, full charset: ~33 seconds to exhaust the entire space.
- 7 chars: ~53 minutes.
- 8 chars: ~3.5 days.
And that is brute force, which is the stupid attack. Real passwords are not uniformly random; they cluster around dictionary words and predictable mutations. A dictionary-plus-rules run finishes most human-chosen passwords long before brute force would, which is precisely why my inherited admin panel collapsed in 120 seconds.
The single worst aggravator in that old schema was the missing salt. Without a per-user salt, identical passwords produce identical hashes, so an attacker can precompute once and look up forever. That precomputed lookup is the rainbow table.
How the rainbow-table attack actually runs
A rainbow table is a time-memory tradeoff. Instead of cracking each hash live, you precompute chains of hash -> reduce -> hash -> reduce ... and store only the endpoints, so you trade disk space for not having to re-run the hash function at attack time. Against unsalted SHA-256 the workflow looks like this:
- The attacker steals your
userstable. Every row is(email, sha256_hex). - They already possess (or download) a table mapping common-password hashes back to plaintext. For unsalted SHA-256 of leaked-password corpora, these are freely circulated.
- They run a single indexed join: every hash that exists in the table is reversed instantly — no per-guess hashing required.
- Whatever survives gets handed to a GPU for live cracking at 22 billion/s.
Here is the code that creates this vulnerability. It is the version I deleted:
// WRONG. This is "encoding," not password hashing.
import { createHash } from 'node:crypto';
function hashPassword(password) {
return createHash('sha256').update(password).digest('hex');
}
// hashPassword("hunter2")
// -> "f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7"
// Deterministic. Identical for every user who picks "hunter2".
// Reversible via a lookup table in O(1).
Notice the input→output example: "hunter2" always maps to that same hex string, on your server, on mine, and in every rainbow table ever published. That determinism is the whole exploit. (You can reproduce that exact digest in the browser with the hash generator — pick SHA-256 and type hunter2. Watching it produce a stable, repeatable string is the clearest demonstration of why it must never guard a login.)
A salt breaks the precomputed table because the attacker would need a separate table per salt value, which defeats the entire point of precomputation. But a salt alone still leaves you exposed to live GPU cracking at 22 billion guesses per second. You need the salt and deliberate slowness.
The fix: a KDF that is slow on purpose
The correct tools are password-based key derivation functions designed to be expensive: Argon2id, bcrypt, and scrypt. They bake in a work factor (and, for Argon2 and scrypt, a memory cost) so you can tune how much compute each single hash demands. Crank it up as hardware improves; the attacker's cost rises with yours.
The contrast is not subtle. On that same RTX 4090, bcrypt benchmarks around 184,000 hashes per second at hashcat's default cost factor of 5 — versus 22 billion for SHA-256. That is already about 120,000× slower, by design. And cost 5 is just the benchmark default: real libraries ship a higher cost (bcrypt cost 10–12 is typical in 2026), and each +1 to the cost doubles the work, so a production cost of 10 drops the GPU to roughly 5–6 thousand guesses per second — pushing the gap past six orders of magnitude. Memory-hard functions like Argon2id and scrypt punish GPUs even harder, because a GPU's thousands of cores choke when each guess demands megabytes of fast RAM.
| Function | Type | ~Hashes/s, 1× RTX 4090 | Salt | Tunable cost | Password use? |
|---|---|---|---|---|---|
| SHA-256 | Fast hash | ~22,000,000,000 | No (manual) | No | No — checksums only |
| bcrypt | Slow KDF | ~184,000 (cost 5); ~5,000 (cost 10) | Yes (built in) | Work factor | Yes |
| scrypt | Memory-hard KDF | far lower (memory-bound) | Yes | CPU + memory | Yes |
| Argon2id | Memory-hard KDF | far lower (memory-bound) | Yes | time + memory + parallelism | Yes — OWASP first pick |
OWASP's first recommendation is Argon2id. bcrypt remains entirely acceptable for existing systems and has a comforting two-decade track record. scrypt is the third option. All three handle salting for you and encode the salt and parameters directly into the output string, so you store one self-describing field.
Here is the right version, using Node's built-in scrypt (zero dependencies) with a per-user salt:
// RIGHT. Per-user salt + deliberately expensive derivation.
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
async function hashPassword(password) {
const salt = randomBytes(16); // unique per user
const derived = await scryptAsync(password, salt, 64); // 64-byte key
return `${salt.toString('hex')}:${derived.toString('hex')}`;
}
async function verifyPassword(password, stored) {
const [saltHex, keyHex] = stored.split(':');
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(keyHex, 'hex');
const actual = await scryptAsync(password, salt, expected.length);
return timingSafeEqual(expected, actual); // constant-time compare
}
Two details that are easy to miss and both load-bearing: the salt is generated fresh per call with a CSPRNG (randomBytes), so no two users share a hash even with identical passwords; and verification uses timingSafeEqual, not ===, so you do not leak comparison timing. In production I would reach for the argon2 package and tune memory cost, but this scrypt snippet runs as-is on any modern Node without installing anything.
Where SHA-256 still belongs
None of this makes SHA-256 bad. I use it constantly — for file checksums, cache keys, deduplication fingerprints, ETags, and content-addressed storage. In all of those, fast and deterministic is exactly the property you want: you need the same input to reliably produce the same digest, and you want it computed in microseconds. There is no adversary trying to reverse a cache key. That is the precise inversion of the password threat model.
This fast/slow distinction shows up elsewhere in auth too. People sometimes assume a signed JWT is "encrypted" and safe to stuff secrets into — it is not; a standard JWT is signed, not encrypted, and its payload is plain base64url. If you ever want to see that for yourself, drop a token into the JWT decoder and watch the claims render in clear text. Same family of mistake as sha256(password): confusing a transformation that looks protective with one that actually resists an attacker.
The one-line takeaway
The number that matters is the ratio: 22 billion versus 184 thousand hashes per second on the same GPU. SHA-256 hands an attacker the fast side of that ratio. Argon2id, bcrypt, and scrypt hand it the slow side — with a salt and a cost knob you control. Store passwords with a KDF, salt every one, tune the work factor as high as your login latency budget tolerates, and keep SHA-256 for the jobs where speed is a feature instead of a liability.
My old admin panel got migrated to bcrypt the same afternoon — re-hashing transparently on next successful login. The two-minute crack never happened again, because the math no longer allowed it.
Tools used in this guide
- SHA-256 Hash Generator — Paste text and generate a SHA-256 digest locally for checksums, examples, cache keys, and debugging.
- JWT Decoder — Paste a JSON Web Token and inspect its header, payload, and signature segment locally in your browser.