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

BcryptSHA-256HMAC-SHA256
TypeAdaptive password hashCryptographic hashKeyed hash (MAC)
PurposePassword storageData integrity verificationMessage authentication
Key/SecretAuto-generated saltNoneRequires a secret key
SpeedIntentionally slow (configurable)Very fastFast
SaltBuilt-in (embedded in output)Needs manual implementationN/A (not for storage)
OutputVariable (60 chars default)Fixed (64 hex chars)Fixed (64 hex chars)
ReversibleNoNoNo

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.

HardwareSHA-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:

  1. Take the leaked SHA-256 hash list
  2. Run through the top 10,000 most common passwords
  3. 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:

  1. Built-in salt — automatically generated, stored in the output string
  2. Configurable work factor — makes the hash intentionally slow
  3. 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

AlgorithmWhy It's Wrong
MD5Preimage attacks, < 1 second per billion hashes
SHA-1Collision attacks demonstrated (SHAttered)
SHA-256/512Too fast, no built-in salt, no work factor
AES (encryption)Reversible — password recovery is the goal
Base64Not 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...UseReason
Store passwords in a databaseBcrypt, Argon2id, or ScryptAdaptive, salted, intentionally slow
Verify file integrity after downloadSHA-256Fast, well-understood, standard
Sign API requestsHMAC-SHA256Keyed authentication, tamper detection
Verify webhook payloadsHMAC-SHA256Shared secret ensures authenticity
Create a checksum for a backupSHA-256Standard tool for integrity
Generate a JWT tokenHMAC-SHA256 (HS256) or RS256Authentication with shared secret or keypair
Hash a value for a cache keySHA-256Fast, low-collision, no security required
Derive an encryption key from a passwordArgon2id or PBKDF2Designed for key derivation
Compare two hashes securelycrypto.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.