Skip to content
yutils

Password Hashing Done Right — bcrypt, Argon2, and What Not to Do

Why you can't store passwords with SHA-256, what makes bcrypt and Argon2 different, how to pick cost parameters, peppering, and migrating between algorithms.

~10 min read

Password storage failures land in the news every year. LinkedIn (2012, SHA-1 unsalted), Yahoo (2013, MD5), Adobe (2013, 3DES). The pattern is always the same: a fast hash, or a regular hash without salt. This guide lays out the right choices as of 2026 and the answers people still get wrong.

Why not SHA-256?

SHA-256 is an excellent general-purpose hash. It's the right tool for document integrity, JWT signing, file checksums — try it in SHA Hash. For passwords, it's the wrong tool for one reason: it is too fast.

A single 2026-era GPU computes SHA-256 at roughly 10 GH/s — 10 billion hashes per second. The total space of an 8-character alphanumeric password is about 2.18 × 1014; eight GPUs brute-force it in about six hours. Even with salt, a targeted user takes the same time. MD5 is faster yet (50+ GH/s) and has known collisions — keep MD5 Hash for checksums only.

A password hash function should be deliberately slow. A legitimate login (one hash) taking 100 ms is invisible to users; a brute-force attempt (billions of hashes) becomes infeasible.

The right choices for 2026

Argon2id (first pick)

Winner of the 2015 Password Hashing Competition, standardised as RFC 9106 (2021). Memory-hard — by forcing the function to use a lot of RAM, GPUs lose their advantage.

  • Three parameters: m (memory in KiB), t (iterations), p (parallelism).
  • OWASP recommendation (2026): m=46 MiB, t=1, p=1. Bump to m=64 MiB if the server can spare it.
  • Use the id variant — defends against both side-channel and GPU attacks. Plain i or d are not recommended.

bcrypt (second pick, when compatibility matters)

Around since 1999, tested by 25+ years of attacks, with a vetted library in every language. The safe fallback when Argon2 isn't available.

  • One parameter: cost (= log2 of the iteration count).
  • OWASP recommendation (2026): cost=12. About 250 ms on an M2 MacBook.
  • Caveat: input is truncated at 72 bytes. For long passwords or passphrases, pre-hash with SHA-256, but watch out for the NULL-byte issue — base64 the SHA-256 output before feeding it to bcrypt.
  • Try it interactively in Bcrypt Hash — see the hash format and timing across costs.

scrypt — third pick

Older than Argon2 but also memory-hard. If you can choose, Argon2 is better. If you already use scrypt, no urgent reason to migrate.

PBKDF2 — not recommended (legacy only)

Skip unless you need NIST FIPS compliance. Weak against GPU acceleration.

Picking the cost parameter

Rule: pick the largest value where a single legitimate login finishes in 100–250 ms. Invisible to users; expensive for attackers.

Measure on your actual production hardware.

import bcrypt from "bcryptjs";
console.time("bcrypt-12");
await bcrypt.hash("test", 12);
console.timeEnd("bcrypt-12");

For Argon2id, measure memory too. With m=46 MiB and p=1, ten concurrent logins consume 460 MiB. Size against peak RAM and concurrency.

Salt — why it matters

A salt is a per-user random value that forces the same password to produce different hashes for different users. It defends against:

  1. Rainbow tables — precomputed (password → hash) maps. With salts, each user requires their own table, making the approach useless.
  2. Bulk attacks — without salt, users sharing a password line up with the same hash, exposing the pattern in any leaked DB.

bcrypt and Argon2 generate the salt themselves and embed it in the hash string. You don't manage it manually.

Pepper — optional

A pepper is a shared secret (env variable, not in the DB) that gets mixed in alongside the password. Adds a layer if only the DB leaks. But:

  • Every server needs the secret to verify — distribution overhead.
  • If the pepper leaks too, you're back to salt-only — no general win.
  • Rotation is painful. Changing it requires re-hashing every record.

For most services Argon2id alone is enough. Add a pepper only in high-security systems.

Password creation policy — for users

NIST 800-63B (revised 2020) key points:

  • Minimum 8 chars, recommended 12+.
  • Don't enforce composition rules — "must contain uppercase, digit, symbol" actually weakens passwords by pushing users to predictable workarounds like Password1!.
  • No periodic expiration — only force a change on suspected compromise. Mandatory rotation pushes users toward weaker passwords.
  • Check against breach corpora — Have I Been Pwned API or a local copy to block commonly leaked passwords.

For the password itself, recommend a 16+ char random string or a 4–5 word passphrase — the kind Password Generator produces.

Algorithm migration

Stuck on MD5, SHA-1, or a too-low bcrypt cost? Two migration patterns:

A. Wrap and rotate (instant uplift)

Wrap every existing hash with the new algorithm in one batch — e.g. bcrypt(sha256(password)). Verify by applying the same chain. Replace with a pure new hash whenever the user logs in.

  • Pros: every stored hash is immediately strengthened, no user action needed.
  • Cons: slightly higher verification cost; you track per-user migration state.

B. Rotate on login (simple)

When a user logs in, you have the plaintext — re-hash with the new algorithm and store. Users who never log in stay on the old hash. Most active users migrate within a month.

  • Pros: simple. One column with an algorithm prefix.
  • Cons: inactive accounts stay weak forever. Exposed on breach.

At large scale, combine A + B — wrap immediately, then organically rotate on login.

Common pitfalls

1. Rolling your own

"Just SHA-256 it 1000 times" is not safe. Use vetted libraries. Hand-rolled Argon2 almost certainly leaks side channels.

2. Reused salt

Same salt for every user = no salt. Library-generated salts avoid this automatically.

3. Logging plaintext

Error loggers that capture the request body record plaintext passwords. Mask them before logging.

4. Hashing in the browser

Hashing the password client-side and shipping the hash, then arguing "the server never sees plaintext" — wrong. That hash becomes the password: a leaked DB lets attackers log in directly. Client-side hashing is an extra layer, not a replacement.

5. Arbitrary length caps

Aside from bcrypt's 72-byte input limit, don't impose 16/32-char caps. It only punishes passphrase users.

Summary

  • Password hashes must be deliberately slow. SHA-256/MD5 are out.
  • 2026 picks: Argon2id first, bcrypt cost 12 for compatibility.
  • Tune cost so a single login takes 100–250 ms on real hardware.
  • Salt is automatic. Pepper is optional. Never roll your own.
  • NIST 800-63B: minimum length only, no composition rules, no periodic expiry, block breached passwords.
  • Migrate with wrap-and-rotate or on-login rotate — be aware that inactive users linger.
Back to guides