bcrypt vs SHA-256: Why a Password Hash Should Be Slow on Purpose

"We hashed the passwords, so they're safe." Both bcrypt and SHA-256 are one-way hashes — but only one survives a database leak. SHA-256 isn't broken; it's fast, and fast is exactly the bug for password storage. Here's how salting, cost factors and GPU guess rates turn the same leak from hours into centuries — plus what bcrypt actually costs you, and why the fast hash is still the right call for API keys in an LLM app.

Banner

Prefer to watch? ▶ bcrypt vs SHA-256 in 100 seconds ✈ Telegram

"We hash our passwords" is the sentence that ends most security reviews. It should start them — because the next question is the one that actually matters: with what?

Both bcrypt and SHA-256 are one-way hashes. Both turn hunter2 into a fixed-length blob you can't read backwards. And yet, when the database leaks, only one of them holds.

The difference is speed. And it's the opposite of what your instincts say.


SHA-256: a brilliant hash, doing the wrong job

Let's be clear about something up front, because the internet gets this wrong constantly: SHA-256 is not broken.

It's one-way. It's collision-resistant. It's the hash under git commits, TLS certificates, Bitcoin, and every checksum you've ever verified. It is a genuinely excellent cryptographic primitive, and it was designed with one dominant goal: be fast. Hash a gigabyte of file in a blink. Hash a million records without noticing.

import hashlib

h = hashlib.sha256(b"hunter2").hexdigest()
# f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7

Same input, same output. Every time. Which is exactly the property you want for a checksum — and exactly the property that kills you here.

Attackers don't reverse your hash

This is the mental model flip. Nobody is sitting there trying to invert SHA-256. That's hard, and they don't need to.

They guess. They take a wordlist — the top 10 million leaked passwords, dictionary words, Summer2024! and its ten thousand cousins — hash each guess, and compare against your dump. It's a race, and the only thing that limits them is how many guesses per second the hardware can do.

With SHA-256, that number is obscene. A single consumer GPU chews through roughly 10,000,000,000 SHA-256 hashes per second. Ten billion. Per second. On one card.

Your leaked user table doesn't survive the weekend. It survives hours.

And without a salt, it's worse

SHA-256 has no salt built in. If two of your users pick the same password, they get the same hash, sitting right next to each other in the dump:

alice@corp.com   f52fbd32b2b3b86f...
bob@corp.com     f52fbd32b2b3b86f...   ← same hash = same password
carol@corp.com   f52fbd32b2b3b86f...   ← this one's popular, crack it once

Crack it once, own three accounts. Worse still, an attacker doesn't even have to do the work — they can precompute a giant table of hash → password once (a rainbow table) and then just look yours up. The cracking cost drops to a database join.


bcrypt: a hash that inverts every property on purpose

bcrypt isn't a general-purpose hash. It's a password hash, and it was designed by someone who had already thought through everything above. It takes SHA-256's virtues and deliberately throws them away.

1. It's slow. That's the entire point. bcrypt is built around a deliberately expensive key-setup step. It cannot be made fast, and it resists the GPU parallelism that makes SHA-256 cracking so cheap.

2. Every hash carries its own random salt. You don't manage it, you don't store it in a second column — bcrypt generates it per password and writes it inside the hash string. Same password, two users, two completely different hashes. Rainbow tables die instantly.

3. The cost is a dial you control. That's the 12 below — the work factor. Cost 12 means 2¹² = 4,096 rounds of key setup. Each +1 doubles the work, forever. Hardware gets faster? Bump the number.

import bcrypt

# Hashing — the salt is generated for you and baked into the output
hashed = bcrypt.hashpw(b"hunter2", bcrypt.gensalt(rounds=12))
# $2b$12$eImiTXuWVxfM37uY4JANjQuwoNw2NvJ2ZbcFTz4dGrvQoOHnBrOZK
#  │   │  └─ the salt, right there in the string
#  │   └──── cost factor: 12
#  └──────── algorithm: bcrypt

# Verifying — no salt column to look up, it's already in the hash
bcrypt.checkpw(b"hunter2", hashed)   # True

Read that output format for a second, because it's the elegant part: the algorithm, the cost, and the salt all travel with the hash. You can raise the cost factor next year and old hashes still verify — they carry their own instructions.

What that does to the attacker

At cost 12, one hash takes roughly 250 milliseconds. For a user logging in, that's imperceptible. For someone with your entire database and a rack of GPUs, it's a wall:

SHA-256bcrypt (cost 12)
Guesses/sec (1 GPU)~10,000,000,000~5,000
SaltNone by defaultPer-password, automatic
Identical passwordsIdentical hashesDifferent hashes
Rainbow tablesEffectiveUseless
Tunable over timeNoYes (cost factor)
Time to crack a leakHoursCenturies
Right jobIntegrity, signatures, HMACPasswords

Same leak. Same hardware. Same passwords. Hours versus centuries — and the only thing that changed is that you picked a hash that refuses to hurry.


What bcrypt actually costs you (it isn't free)

Anyone who sells you bcrypt as a pure win is skipping the invoice. There are three real costs, and all three have bitten production systems.

1. You pay the 250 ms on every single login. That's CPU, on your servers, per authentication. It's fine at a trickle. But a Monday-morning login storm — or a credential-stuffing bot hammering /login — turns a traffic spike into a CPU spike, and your own auth endpoint becomes the DoS. The fix isn't to lower the cost until it stops hurting; it's to rate-limit login attempts and size the box for the peak.

2. The work factor is a knob you have to keep turning. A cost that was painful for attackers in 2015 is comfortable for them now. The number isn't set-and-forget — it's a budget: pick the highest cost that keeps you around ~250 ms on your hardware, and re-measure every couple of years. (Because old hashes carry their own cost factor, you can upgrade lazily: on a successful login, if the stored cost is below your current target, re-hash and store.)

3. bcrypt silently truncates past 72 bytes. This one is a genuine footgun. Feed bcrypt a long passphrase and everything beyond byte 72 is ignored — no error, no warning. Two different 80-character passphrases sharing a 72-byte prefix will happily verify against each other. If you encourage long passphrases (you should), you need to know this.

The escape hatch: if any of that makes you nervous, reach for argon2id instead. It's the modern recommendation — no truncation, and it's tunable on memory as well as time, which makes GPU and ASIC attacks even more expensive. bcrypt is fine, battle-tested, and everywhere; argon2id is what you'd pick starting fresh today. Either one is a correct answer. SHA-256 is not.


The AI-era version of the same mistake

Here's where this gets freshly relevant, because the same decision shows up in every LLM app being built right now — and the right answer flips.

Your AI product ships an API. Every inference request arrives with an API key (sk-live-9f3a...), and you have to check it against the database on every call. So: bcrypt, right? Slow is safe, we just established that.

No. Do that and you've bolted 250 ms of CPU onto every single request to your model endpoint — on an endpoint whose whole selling point may be a sub-second time-to-first-token. You've made your auth layer slower than your LLM, and you've handed anyone with a load generator a trivial way to melt your gateway.

The reason it flips is the thing bcrypt was compensating for in the first place: entropy.

  • A password is chosen by a human. It's low-entropy, it's guessable, it's in a wordlist. bcrypt's slowness exists to make guessing uneconomical.
  • An API key is generated by you, from a CSPRNG, with 256 bits of randomness. It is not in any wordlist. There is nothing to guess. Ten billion guesses per second against a 256-bit random key is still, functionally, forever.

So for high-entropy secrets you've generated yourself — API keys, session tokens, password-reset tokens, webhook signatures — the fast hash is the correct hash. Store SHA-256(key), look it up by that digest on every request, and compare in constant time. Fast lookup, nothing sensitive at rest, no CPU tax on your hot path.

import hashlib, secrets

# Issue: 256 bits from a CSPRNG. Show it to the user exactly once.
raw_key = "sk-live-" + secrets.token_urlsafe(32)

# Store: only the fast digest. A leaked table of these is worthless —
# there's no wordlist for 256 random bits.
key_digest = hashlib.sha256(raw_key.encode()).hexdigest()

# Verify, on every inference request: one hash, one indexed lookup.
# Constant-time compare to avoid leaking the digest byte-by-byte.
secrets.compare_digest(key_digest, row.key_digest)

Same two algorithms. Opposite verdict. Because the question was never "which hash is stronger" — it was always "what is the attacker's cheapest path in?" For a human-chosen password, that path is guessing, so you make guessing slow. For a 256-bit random token, that path doesn't exist, so you optimize for the thing that does matter: throughput.

That's the real skill. Not memorizing "bcrypt good, SHA-256 bad" — but knowing which threat you're actually paying to defend against.


The verdict

The whole lesson compresses into one line:

The property you want in a password hash is the exact opposite of the one you want everywhere else. Slow, not fast.

  • Storing user passwords?bcrypt (cost ~12, budget the 250 ms, rate-limit your login endpoint, mind the 72-byte limit) — or argon2id if you're starting fresh.
  • Integrity, checksums, git objects, digital signatures, HMAC, hashing high-entropy API keys?SHA-256, and don't feel bad about it for a second. That's the job it was built for, and it's superb at it.

SHA-256 isn't broken. It never was. It's just the wrong tool for this one job — and "we hashed it" was never the same sentence as "it's safe."

So: which one is in your users table?

Want the 100-second visual version? Watch the reel.

bcrypt vs SHA-256: Why a Password Hash Should Be Slow on Purpose | Vahid Aghajani