JsonMind
Security & Authentication15 min readUpdated Nov 9, 2026

Password Hashing: Why SHA-256 Fails and How bcrypt Works

Understand why fast hashes fail for passwords, how bcrypt and Argon2id work, and which settings and operational controls actually protect stored credentials.

JMBy JsonMind EngineeringLast reviewed: Nov 9, 2026Reviewed against primary specifications and linked sources.
Quick answer

Passwords need a deliberately slow, salted password-hashing algorithm such as Argon2id, scrypt, or bcrypt. SHA-256 is designed to be fast, so GPUs can test billions of candidates per second. Never store a plain SHA-256 digest of a password, and never encrypt passwords reversibly.

Key takeaways
  • General-purpose hashes such as SHA-256 and MD5 are too fast for password storage.
  • bcrypt includes a salt and a cost factor; Argon2id also adds memory hardness and is the preferred modern choice.
  • Use a unique salt per password and a constant-time comparison during verification.
  • Password hashing is not encryption; you should never be able to recover the original password.
  • Add rate limiting, MFA, breach monitoring, and a pepper stored outside the database.

Password storage is one of the few places where "fast" is a defect. A hash that takes milliseconds is convenient for a login endpoint and equally convenient for an attacker with a stolen database.

This guide explains the threat model, compares the common algorithms, and shows the operational controls that matter beyond the hash function itself.

The threat model

Assume an attacker has stolen your users table. What can they do?

  • If you stored plaintext, they have every password.
  • If you stored a fast hash such as SHA-256, they can test candidate passwords at billions per second on a GPU.
  • If you stored a slow, salted hash, each guess costs significantly more time and memory.
  • If you encrypted the password, a stolen key decrypts everything.

The difference is not a small constant. The exact rates depend on hardware and implementation, but the orders of magnitude are what matter:

Stored formApproximate offline guesses per secondPractical meaning
Plaintext or reversible encryptionunlimitedNo cracking required
MD5 or SHA-1tens of billionsExhausts common password spaces quickly
SHA-256billions to tens of billionsA GPU tests a large candidate list per second
bcrypt, cost 12thousandsEach guess costs CPU and memory bandwidth
Argon2id, 19 MiB, t=2hundreds to low thousandsEach guess allocates memory, limiting parallelism

The table is a planning aid, not a benchmark. The point is that a fast hash turns a stolen database into a short offline job, while a slow hash turns it into a long, expensive one. If an attacker can test 10 billion SHA-256 candidates per second, an eight-character password from a common pattern falls quickly; the same database with bcrypt at cost 12 reduces the rate by six or seven orders of magnitude.

Password hashing does not prevent a breach. It limits the damage after one.

Why SHA-256 is the wrong tool

SHA-256 is a cryptographic hash designed for integrity and speed. Modern GPUs can compute enormous numbers of SHA-256 hashes per second. A salted SHA-256 hash defeats precomputed rainbow tables, but it does not slow down a targeted guessing attack.

SHA-256: fast, deterministic, no built-in salt
bcrypt:  slow by design, includes salt and cost
Argon2:  slow and memory-hard, tunable

Using SHA-256 for passwords is like locking a door with a lock that opens instantly for anyone who tries enough keys.

A concrete test vector makes the problem visible. SHA-256("password") is 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8, and a single GPU can compute that digest for billions of candidates per second. Adding a salt changes the input but not the speed: SHA-256(salt + password) still costs one compression function. In hashcat terms, raw SHA-256 is mode 1400 and salted SHA-256 is mode 1420; both are optimized for throughput, while bcrypt is mode 3200 and is orders of magnitude slower per guess. The attacker does not need a rainbow table for a targeted account; they need a wordlist, a rule set, and a few hours.

A pepper does not fix a fast hash. If the pepper is stored with the database, the attacker has it; if it is stored elsewhere, it raises the cost of the first guess but not the per-guess cost once the pepper leaks. HMAC-SHA-256 with a pepper is still a fast function.

SHA-256 is the right tool for other jobs: file integrity, content addressing, signatures over documents, and deriving keys from high-entropy secrets. The rule is about the input, not the algorithm: a password is low-entropy and guessable, so the function must be slow. A 128-bit random API key has no guessable structure, and a fast hash is acceptable there, provided you compare it in constant time and never log the key.

How bcrypt works

bcrypt derives a hash from the password, a salt, and a cost factor. The cost factor is exponential: each increment doubles the work.

$2b$12$Fh8sK2...   <- algorithm, cost, salt, hash

Key properties:

  • A unique salt is generated for each password.
  • The cost factor can be increased as hardware improves.
  • The output includes the algorithm, cost, and salt, so verification needs only the stored string.
  • The input is limited to 72 bytes; longer passwords must be pre-hashed or rejected with a clear rule.

Verification uses a constant-time comparison provided by the library:

import bcrypt from "bcryptjs";

const hash = await bcrypt.hash(password, 12);
const valid = await bcrypt.compare(candidate, hash);

Do not compare hash strings with === in your own code. Use the library's comparison function, which is designed to avoid timing leaks.

The stored string is self-describing: $2b$12$ is the version and cost, the next 22 characters are the 128-bit salt encoded in a bcrypt-specific Base64 variant, and the final 31 characters are the hash. A full record is 60 characters. $2a$ and $2y$ appear in older or PHP-generated records; most libraries accept all three, but a cross-language migration should verify a known hash rather than assume. The cost is logarithmic: cost 10 is 1,024 iterations, cost 12 is 4,096, and each increment doubles the work and roughly doubles verification time.

The 72-byte limit is a real correctness bug, not a theoretical footnote. With bcryptjs, hashing "a".repeat(72) + "X" and then comparing "a".repeat(72) returns true, because the trailing X is silently truncated. A user who chooses a 73-byte passphrase and later changes only the last character can still log in with the old value, and two distinct long passwords can share a hash. The fix is to pre-hash the password with SHA-256 or HMAC-SHA-256, encode the digest (Base64 or hex), and feed that fixed-length string to bcrypt, or to reject inputs over 72 bytes with a clear message. Pre-hashing has its own trap: do not use a raw digest with embedded NUL bytes in implementations that treat NUL as a terminator; Base64 or hex avoids the issue. Document which scheme you chose, because changing it later invalidates every stored hash.

Cost selection is a latency budget decision. Measure the actual compare time on production hardware at peak concurrency, not on a laptop. If cost 12 takes 250 ms and your login endpoint allows 40 concurrent attempts per instance, the CPU budget is saturated before the database is; queueing and rate limiting are part of the design. Re-benchmark after hardware changes, and store the cost in the hash so old records can be upgraded on login.

How Argon2id works

Argon2id is a memory-hard password-hashing function. It won the Password Hashing Competition and is recommended by OWASP for new systems.

Parameters:

  • Memory: how much RAM each hash uses.
  • Iterations: how many passes over the memory.
  • Parallelism: how many lanes can run concurrently.
import argon2 from "argon2";

const hash = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 19456, // KiB
  timeCost: 2,
  parallelism: 1
});

const valid = await argon2.verify(hash, candidate);

Memory hardness raises the cost of GPU and ASIC attacks because each guess needs a large allocation. The exact parameters depend on your hardware and latency budget; benchmark them rather than copying a value blindly.

The stored format is a PHC string, for example $argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$.... The parameters are embedded, so verification reads them from the record and does not need a separate configuration lookup. The OWASP minimum for Argon2id is 19 MiB of memory, two iterations, and one lane (m=19456,t=2,p=1); a higher-memory configuration such as 64 MiB with three iterations is stronger if the latency budget allows. parallelism is the number of lanes, not the number of CPU cores you dedicate; increasing it can make the function faster on a multi-core attacker, so keep it modest unless you have measured the tradeoff.

Argon2id is a hybrid: the first half of the first pass is data-independent (like Argon2i, resisting side-channel attacks), and the rest is data-dependent (like Argon2d, resisting time-memory tradeoffs). That is why it is the recommended variant rather than plain Argon2i or Argon2d.

Memory cost creates an availability risk that bcrypt does not. At 19 MiB per hash, 100 concurrent logins allocate about 1.9 GiB, and a burst of credential-stuffing requests can exhaust the process or trigger the OOM killer before rate limiting takes effect. Cap concurrent hashing operations with a semaphore or queue, return 429 Too Many Requests with Retry-After when the queue is full, and monitor allocation failures. A password hash that is secure but takes down the service under load is not a successful design.

scrypt

scrypt is also memory-hard and widely available. It is a reasonable choice when Argon2id is not available. Its parameters are N (CPU/memory cost), r (block size), and p (parallelization). As with Argon2, benchmark and document the values.

The memory footprint is approximately 128 * N * r bytes, so N=2^17, r=8, p=1 needs about 128 MiB per hash. That is the OWASP minimum for scrypt, and it is far more than a default configuration. In Node.js, crypto.scrypt has a default maxmem of 32 MiB; passing those parameters without raising it throws RangeError [ERR_CRYPTO_INVALID_SCRYPT_PARAMS]: Invalid scrypt params: ... memory limit exceeded. Raising maxmem fixes the immediate error but makes the concurrency and OOM problems worse, so size the pool before increasing it. A PHC-style record such as $scrypt$ln=17,r=8,p=1$... stores the parameters with the hash; if your library uses a different encoding, document it.

scrypt is older than Argon2 and lacks Argon2id's hybrid side-channel resistance, but it has been analyzed for years and is a defensible choice in environments where Argon2 is unavailable or not packaged. Do not invent your own KDF by combining SHA-256 and a loop; use a reviewed implementation.

Salts, peppers, and secrets

A salt is unique per password and stored with the hash. It prevents identical passwords from producing identical hashes and defeats precomputed tables.

A pepper is a secret value applied to all passwords and stored outside the database, for example in an environment secret or KMS. It adds defense in depth: a database-only breach does not provide the pepper. A pepper is not a substitute for a slow hash, and rotating it requires rehashing on login.

stored = slow_hash(password + pepper, unique_salt)

Do not store the pepper in the same database as the hashes.

The details determine whether the pepper helps. Use a salt of at least 16 bytes from a cryptographically secure random generator, generated per password and stored alongside the hash; a deterministic salt derived from the email is better than a global constant but still allows precomputation across a known user list. For the pepper, apply it as HMAC-SHA-256(pepper, password) before the slow hash for bcrypt, or pass it as Argon2's dedicated secret parameter, which is designed for exactly this purpose. Argon2's secret is mixed into the computation and is not stored in the PHC string. If you prepend the pepper as plain text (pepper + password), a length-extension or encoding mistake is easier to make; HMAC or the built-in secret parameter is cleaner.

Rotation needs a version. Store pepper_version with each hash, keep the current and previous peppers available during a transition, try the current first and then the old one, and rehash on successful login. A pepper rotation without dual-read support locks every user out with a generic "invalid password" error, and the support team cannot distinguish it from a real breach. Test the rotation in staging with production-like data before touching the live secret.

Verification and migration

Store the algorithm and parameters with each hash. When verifying, read the algorithm identifier and dispatch:

type StoredPassword =
  | { algorithm: "bcrypt"; hash: string }
  | { algorithm: "argon2id"; hash: string }
  | { algorithm: "sha256-legacy"; hash: string };

On successful login, if the stored hash uses an old algorithm or a weak cost, rehash the password with the current policy and update the record. This lets you migrate without forcing a password reset.

Concurrency makes the update trickier than it looks. Two simultaneous logins can both verify the old hash, both derive a new hash with different salts, and both write; the last write wins, which is fine because both new hashes validate the same password. A worse race is a password change landing between verification and rehash: the rehash overwrites the new password with the old one. Use a conditional update such as UPDATE users SET password_hash = $new WHERE id = $id AND password_hash = $old; if zero rows are affected, discard the rehash and let the next login retry. Most libraries expose a needsRehash or getRounds check, so the policy lives in one place rather than in scattered comparisons.

Migration cannot recover users who never log in. Track the count of legacy hashes by algorithm and set a deadline: after it passes, force a password reset for the remaining accounts rather than keeping a fast hash indefinitely. A legacy SHA-256 column that is "temporarily" retained for three years is a standing liability.

The verification path itself should have tests: a correct password returns true, a wrong password returns false, a hash with an old cost triggers rehash, a hash with an unknown algorithm fails closed instead of throwing a 500, and a 73-byte password behaves as documented.

Operational controls that matter

A strong hash does not compensate for weak authentication flows:

  • Rate-limit login attempts by account and by IP.
  • Use MFA for sensitive accounts.
  • Monitor breached-password lists and force a reset when a match is found.
  • Use HTTPS everywhere.
  • Avoid logging passwords, reset tokens, or session identifiers.
  • Expire reset tokens quickly and make them single-use.
  • Use a constant response time for unknown users and wrong passwords to reduce account enumeration.

Timing is easy to get wrong even when the code looks correct. If an unknown email returns in 5 ms because no hash is computed, and a known email with a wrong password returns in 250 ms because bcrypt ran, an attacker can enumerate accounts with a stopwatch. Fix it by verifying against a fixed dummy hash when the user does not exist, so both paths pay the same cost, and return the same generic error message and status code. Log the real reason server-side, not in the response.

Rate limiting has its own tradeoff. Per-account lockout stops a targeted attack but lets an attacker lock a victim out; per-IP limits are weak against distributed botnets; a global limit protects the service but not an individual account. Use layered signals: exponential backoff per account, IP reputation, device fingerprinting, and a CAPTCHA or MFA challenge after a threshold. Return 429 with Retry-After rather than a generic 403, and make sure the limiter itself does not become an unauthenticated denial-of-service vector.

MFA and breach monitoring address attacks that a password hash cannot. A correctly hashed password that is reused on a breached site is still a valid credential; check new passwords against a breached-password corpus (using k-anonymity, so the password is not sent in full) and force a reset on a hit. None of these controls replace the slow hash, and the slow hash does not replace them.

Common mistakes

MistakeRiskFix
SHA-256 or MD5 for passwordsFast offline crackingUse Argon2id, scrypt, or bcrypt
Same salt for every userRainbow tables and shared cracksGenerate a unique salt per password
Encrypting passwordsKey theft exposes all passwordsHash, do not encrypt
Comparing hashes with ===Timing side channelsUse the library's verify function
Ignoring bcrypt's 72-byte limitSilent truncationPre-hash or enforce a documented limit
Storing the pepper in the databaseNo defense in depthStore it in a secret manager
No migration pathLegacy fast hashes persistRehash on successful login
Pre-hashing without documenting the schemeFuture verifiers cannot reproduce itVersion the scheme and test cross-language
Argon2 with a low memory settingGPU attacks stay cheapUse the OWASP minimum and benchmark
No concurrency cap on memory-hard hashesOOM under a login burstQueue work and return 429
Hashing high-entropy API keys with bcryptWasted CPU at every requestUse HMAC-SHA-256 and constant-time compare
No pepper version fieldRotation locks out every userStore and dual-read the version

Testing hashes

The password hash generator can produce bcrypt and other hashes for development. The bcrypt verifier and scrypt verifier help confirm that a stored hash matches a candidate without writing a script. The SHA-256 generator is useful for integrity checks, not for passwords.

Use test values only. Do not paste production password hashes into a public tool, and do not use a browser tool as part of your production authentication path.

A useful local check is to generate a hash with cost as low as the library allows for speed, verify the correct password, verify a wrong password, and confirm the wrong case returns false rather than throwing. Then repeat with a deliberately long password (73 bytes or more) to see whether your code truncates, rejects, or pre-hashes. For cross-language work, generate a bcrypt $2b$ hash in one runtime and verify it in another; PHP's password_hash emits $2y$, and some libraries require an explicit compatibility flag. For Argon2, verify the PHC string's version and parameters are preserved after a database round trip; a TEXT column that is too short silently cuts the hash, and every login fails with a format error that looks like a wrong password. The bcrypt and scrypt verifier tools on this site are useful for these checks with test values, not for production records.

A recommendation

For new systems, use Argon2id with parameters benchmarked on your hardware. If your platform or compliance requirements favor bcrypt, use a cost factor that keeps verification within your latency budget and handle the 72-byte limit explicitly. Migrate legacy fast hashes on login, add a pepper stored outside the database, and treat authentication as a system: hashing is one layer, not the whole defense.

The recommendation is wrong when a compliance regime requires a FIPS 140-validated cryptographic module. Argon2id and bcrypt are not FIPS-approved password hashing functions; a validated deployment typically uses PBKDF2-HMAC-SHA-256 with a high iteration count (OWASP suggests 600,000 as a floor for PBKDF2-HMAC-SHA-256) inside the validated boundary, or a vendor-provided password hashing service. Verify the exact requirement with the compliance owner rather than assuming; "FIPS mode" in a runtime does not automatically make an unapproved algorithm compliant.

Before shipping, confirm five things: every stored password uses a unique salt; the algorithm and cost are embedded in the record; the verification path uses the library's constant-time comparison; there is a tested rehash-on-login path and a deadline for legacy hashes; and login, reset, and registration share the same rate limiting and enumeration defenses. If any of those is missing, the choice between bcrypt and Argon2id is not the most important problem.

Frequently asked questions

Can I use SHA-256 with a salt for passwords?

Salting prevents identical passwords from producing identical hashes, but SHA-256 is still too fast. An attacker with a GPU can test enormous numbers of salted guesses. Use a password-hashing algorithm with a tunable cost, such as Argon2id, scrypt, or bcrypt.

Is bcrypt still safe?

bcrypt remains acceptable when used with a sufficient cost factor and its 72-byte input limit is handled correctly. Argon2id is generally preferred for new systems because it adds memory hardness, which raises the cost of GPU and ASIC attacks.

Should I encrypt passwords instead of hashing them?

No. Encryption is reversible; a stolen key exposes every password. Password verification only needs to compare a derived value, so hashing is the correct design.

What bcrypt cost factor should I use?

Choose the highest cost your authentication service can afford within its latency budget, then re-evaluate as hardware improves. A common starting point is 12, but the right value depends on your traffic and hardware. Measure the actual verification time.

How do I migrate from SHA-256 to bcrypt or Argon2?

On the next successful login, verify the old hash, rehash the password with the new algorithm, and replace the stored value. Mark the algorithm in the stored record so the verifier knows which path to use. Expire unused legacy hashes over time.

References

Put this guide into practice

Browse all tools

Open the browser tool. Inputs stay in your browser and are not uploaded to the business server.

Related guides

All guides