Bcrypt vs SHA-256 vs HMAC: Which Hash Should You Use and When?
I once reviewed a codebase where passwords were stored as raw SHA-256 hashes. No salt, no iteration count, no key derivation. The developer had read that "SHA-256 is secure" and stopped there.
SHA-256 is secure — as a cryptographic hash function. It is not secure — as a password storage mechanism. The difference matters enormously in practice.
Bcrypt, SHA-256, and HMAC all produce fixed-size outputs from variable-size inputs. They all look like gibberish. But they solve completely different problems, and using the wrong one creates vulnerabilities that are invisible to casual testing.
The Three Algorithms at a Glance
| Bcrypt | SHA-256 | HMAC-SHA256 | |
|---|---|---|---|
| Type | Adaptive password hash | Cryptographic hash | Keyed hash (MAC) |
| Purpose | Password storage | Data integrity verification | Message authentication |
| Key/Secret | Auto-generated salt | None | Requires a secret key |
| Speed | Intentionally slow (configurable) | Very fast | Fast |
| Salt | Built-in (embedded in output) | Needs manual implementation | N/A (not for storage) |
| Output | Variable (60 chars default) | Fixed (64 hex chars) | Fixed (64 hex chars) |
| Reversible | No | No | No |
SHA-256: Data Integrity, Not Password Storage
SHA-256 is a member of the SHA-2 family, designed by the NSA and published by NIST. It produces a 256-bit (32-byte) hash from any input.
What SHA-256 Is For
File integrity verification:
const crypto = require('crypto');
const fs = require('fs');
function hashFile(filepath) {
const data = fs.readFileSync(filepath);
return crypto.createHash('sha256').update(data).digest('hex');
}
// Verify a download
const expected = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const actual = hashFile("downloaded-file.zip");
console.log(expected === actual ? "File OK" : "File corrupted");
Checksums in build pipelines:
# Generate a checksum file
sha256sum release-v2.1.0.tar.gz > checksums.txt
# Verify later
sha256sum -c checksums.txt
Data deduplication and change detection:
function hashData(data) {
return crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
}
// Cache key based on content hash
const cacheKey = hashData(apiResponse);
Why SHA-256 Is Wrong for Passwords
SHA-256 is designed to be fast. Really fast. A modern GPU can compute billions of SHA-256 hashes per second.
| Hardware | SHA-256 hashes/sec |
|---|---|
| Consumer CPU (single core) | ~50 million |
| Consumer GPU (RTX 4090) | ~15 billion |
| ASIC miner | ~100+ trillion |
If your password database is hashed with raw SHA-256 and an attacker gains access, they can:
- Take the leaked SHA-256 hash list
- Run through the top 10,000 most common passwords
- At 15 billion hashes/second, test all 10,000 against every hash in the entire database in less than a microsecond
Even with a salt (which SHA-256 has no built-in support for), the attack is only marginally slower. Salt prevents rainbow tables but doesn't slow down brute-force attacks, and SHA-256's speed makes brute force trivial.
The Salt Fallacy
// This is still wrong:
function badPasswordHash(password, salt) {
return crypto.createHash('sha256').update(salt + password).digest('hex');
}
Adding salt prevents precomputation (rainbow tables), but it doesn't slow down the hash itself. On a GPU, computing SHA-256(salt + guess) is essentially as fast as SHA-256(guess). The salt is correct but insufficient.
Bcrypt: Password Storage Designed for the Job
Bcrypt was designed for exactly one purpose: storing passwords. It incorporates three essential properties that SHA-256 lacks:
- Built-in salt — automatically generated, stored in the output string
- Configurable work factor — makes the hash intentionally slow
- Adaptive — you can increase the work factor as hardware improves
How Bcrypt Works
const bcrypt = require('bcrypt');
async function hashPassword(password) {
const saltRounds = 12;
const hash = await bcrypt.hash(password, saltRounds);
// $2b$12$LJ3m4ys3Lk0TSwHn9G1xIuR5vK7F8cB1a2D3e4F5g6H7j8K9l0M1N2O
return hash;
}
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
The bcrypt output encodes everything the verifier needs:
$2b$12$LJ3m4ys3Lk0TSwHn9G1xIuR5vK7F8cB1a2D3e4F5g6H7j8K9l0M1N2O
││ ││ ││──────────────────────────────────────────────────────│
││ ││ hash (53 chars base64)
││ │salt (22 chars base64)
││ cost (iteration count = 2^12)
│version
The cost factor of 12 means 2^12 = 4096 iterations. Each comparison takes about 250ms on modern hardware — slow enough to make brute-force impractical at scale.
Choosing the Right Cost Factor
// Too low — vulnerable to brute force
const weak = await bcrypt.hash(password, 4); // 2^4 = 16 iterations
// Good — balances security and UX (2026 recommendation)
const good = await bcrypt.hash(password, 12); // 2^12 = 4096 iterations
// Paranoid — for high-security systems
const paranoid = await bcrypt.hash(password, 14); // 2^14 = 16384 iterations
Guidelines for choosing the cost factor:
- 8-10: Minimum acceptable today. ~50-200ms per hash.
- 12: Recommended for most applications. ~250ms per hash.
- 14: High-security systems. ~1 second per hash. Noticeable for users during login.
The cost should be as high as your infrastructure can tolerate during peak login load. A cost of 12 on a login form that gets 10 requests/second is fine. On a service that validates 1000 tokens/second, you'd want to cache results.
Password Hashing Alternatives
Bcrypt is not the only option, and in 2026, Argon2 is increasingly preferred:
Argon2id (recommended for new systems):
const argon2 = require('argon2');
async function hashPassword(password) {
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 1 // single thread
});
return hash;
}
Argon2id is the winner of the Password Hashing Competition (2015) and is now the recommended choice for new systems. It resists both GPU (memory-hard) and side-channel attacks. Bcrypt remains a solid choice for existing systems — it's well-audited, widely supported, and not broken.
Scrypt (good middle ground):
const crypto = require('crypto');
crypto.scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, key) => {
// 64-byte derived key
});
Scrypt is memory-hard like Argon2 but not as thoroughly analyzed. It's a reasonable choice if Argon2 isn't available (e.g., legacy Node.js versions).
What Not to Use for Passwords
| Algorithm | Why It's Wrong |
|---|---|
| MD5 | Preimage attacks, < 1 second per billion hashes |
| SHA-1 | Collision attacks demonstrated (SHAttered) |
| SHA-256/512 | Too fast, no built-in salt, no work factor |
| AES (encryption) | Reversible — password recovery is the goal |
| Base64 | Not a hash at all — trivially reversible |
HMAC: Authentication, Not Storage
HMAC (Hash-based Message Authentication Code) combines a hash function with a secret key. It answers a different question: "Was this message created by someone who knows the secret?"
What HMAC Is For
API request signing:
const crypto = require('crypto');
function signRequest(method, path, body, timestamp, secret) {
const payload = `${method}\n${path}\n${timestamp}\n${JSON.stringify(body)}`;
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}
// Client creates signature
const sig = signRequest('POST', '/api/order', orderData, Date.now(), API_SECRET);
// Server verifies
const expected = signRequest('POST', '/api/order', orderData, receivedTimestamp, SHARED_SECRET);
if (sig === expected) {
// Request is authentic and hasn't been tampered with
}
Webhook verification (e.g., Stripe, GitHub):
function verifyWebhook(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Use timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
JWT token signing (HS256):
// HS256 is HMAC-SHA256 used as a JWT signing algorithm
const jwt = require('jsonwebtoken');
const token = jwt.sign({ userId: 123 }, JWT_SECRET, { algorithm: 'HS256' });
HMAC ensures:
- Integrity: The message hasn't been modified.
- Authentication: The message came from someone with the secret.
- Non-repudiation: (In symmetric key context) The sender can't deny sending it.
Why HMAC Is Not for Password Storage
HMAC does not include a salt. Multiple users with the same password produce the same HMAC output (when using the same secret). HMAC is also designed to be fast — a property that's good for request signing but bad for password storage.
// Wrong: using HMAC as a password hash
function badPasswordHmac(password, secret) {
return crypto.createHmac('sha256', secret).update(password).digest('hex');
}
// Two users with password "password123" produce identical hashes
const user1 = badPasswordHmac("password123", APP_SECRET);
const user2 = badPasswordHmac("password123", APP_SECRET);
// user1 === user2 — no salt separation!
Even with a manual salt, HMAC doesn't support a configurable work factor. An attacker with GPU hardware can brute-force HMAC-SHA256 at rates comparable to SHA-256.
When You'd Use HMAC with Password Storage
There's exactly one legitimate password-storage use of HMAC: as part of a key derivation function. PBKDF2 (Password-Based Key Derivation Function 2) uses HMAC internally:
const crypto = require('crypto');
function pbkdf2Hash(password, salt) {
return new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, 310000, 64, 'sha512', (err, key) => {
if (err) reject(err);
else resolve(key.toString('hex'));
});
});
}
PBKDF2 uses HMAC to stretch the password into a derived key, running it through thousands of iterations. This is fine but less GPU-resistant than bcrypt or Argon2 because HMAC is relatively cheap to compute on specialized hardware.
Quick Reference: Which Algorithm for Which Job
| You need to... | Use | Reason |
|---|---|---|
| Store passwords in a database | Bcrypt, Argon2id, or Scrypt | Adaptive, salted, intentionally slow |
| Verify file integrity after download | SHA-256 | Fast, well-understood, standard |
| Sign API requests | HMAC-SHA256 | Keyed authentication, tamper detection |
| Verify webhook payloads | HMAC-SHA256 | Shared secret ensures authenticity |
| Create a checksum for a backup | SHA-256 | Standard tool for integrity |
| Generate a JWT token | HMAC-SHA256 (HS256) or RS256 | Authentication with shared secret or keypair |
| Hash a value for a cache key | SHA-256 | Fast, low-collision, no security required |
| Derive an encryption key from a password | Argon2id or PBKDF2 | Designed for key derivation |
| Compare two hashes securely | crypto.timingSafeEqual() | Prevents timing side-channel attacks |
The One-Shot Tool That Helps
When you're debugging hashing issues — checking whether two strings produce the same hash, verifying an HMAC signature, or testing a bcrypt hash against a known input — use the hash generator to compute hashes interactively without writing test code.
For password-related work specifically:
- The bcrypt hash & verify tool lets you hash passwords and verify existing hashes
- The HMAC generator lets you test HMAC signatures with different keys and algorithms
- The hash generator supports SHA-256, SHA-512, MD5, and other algorithms for integrity verification
FAQ
Can I use SHA-256 for passwords if I add salt?
No. Salt prevents rainbow table attacks but doesn't slow down brute-force attacks. SHA-256 is still too fast for password storage regardless of salting. Use bcrypt or Argon2id.
Is HMAC stronger than bcrypt?
They're not comparable. HMAC verifies message authenticity with a secret key. Bcrypt stores passwords securely. They solve different problems.
Which is faster, bcrypt or SHA-256?
SHA-256 is orders of magnitude faster. That's why SHA-256 is good for integrity checks and bcrypt is good for passwords. Speed is desirable for one use case and catastrophic for the other.
Should I migrate from bcrypt to Argon2id?
Argon2id is technically superior (memory-hard, resistance to GPU and side-channel attacks). Whether to migrate depends on your threat model. Bcrypt is not broken — it remains a secure choice for password storage. Migrate if you're building a new system (start with Argon2id) or if your compliance requirements explicitly require it.
What's timing-safe comparison and why does it matter?
Regular string comparison (===) stops at the first mismatched character. An attacker can measure the comparison time to determine how many characters matched, enabling a byte-by-byte brute force. crypto.timingSafeEqual() takes constant time regardless of where the mismatch occurs.
Can I decode a hash back to its original value?
No. Cryptographic hashes are one-way functions. There is no "decrypt" operation. If someone offers a "hash decrypter" or "SHA-256 decrypter," they're running a lookup table or brute-force search, not reversing the hash.