---
title: "bcrypt vs SHA-256: Why a Password Hash Should Be Slow on Purpose"
description: "\"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."
keywords: "bcrypt vs SHA-256, password hashing, bcrypt cost factor, salting passwords, argon2id, GPU password cracking, rainbow tables, appsec, secure password storage, HMAC, API key hashing, backend security, system design"
created_at: "2026-07-13T16:00:00"
post_type: "anonym_post"
content_type: "technical_article"
---

![Banner](./banner.webp)

<div style="display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; padding: 1rem 1.25rem; margin: 1.5rem 0 2rem; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; border-radius: 12px;">
  <span style="font-size: 0.95rem; font-weight: 600; color: #475569; margin-right: 0.25rem;">Prefer to watch?</span>
  <a href="https://youtube.com/shorts/u7OUnPBo4pk" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #ff0000; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">▶ bcrypt vs SHA-256 in 100 seconds</a>
  <a href="https://t.me/SoftwareEngineerBlog" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #229ed9; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">✈ Telegram</a>
</div>

"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.

```python
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:

```text
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.

```python
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:

<table>
  <thead>
    <tr><th></th><th>SHA-256</th><th>bcrypt (cost 12)</th></tr>
  </thead>
  <tbody>
    <tr><td>Guesses/sec (1 GPU)</td><td>~10,000,000,000</td><td>~5,000</td></tr>
    <tr><td>Salt</td><td>None by default</td><td>Per-password, automatic</td></tr>
    <tr><td>Identical passwords</td><td>Identical hashes</td><td>Different hashes</td></tr>
    <tr><td>Rainbow tables</td><td>Effective</td><td>Useless</td></tr>
    <tr><td>Tunable over time</td><td>No</td><td>Yes (cost factor)</td></tr>
    <tr><td>Time to crack a leak</td><td><strong>Hours</strong></td><td><strong>Centuries</strong></td></tr>
    <tr><td>Right job</td><td>Integrity, signatures, HMAC</td><td>Passwords</td></tr>
  </tbody>
</table>

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.

```python
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.](https://youtube.com/shorts/u7OUnPBo4pk)
