Passwords & hashing

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Never store passwords as text. Hash them with bcrypt or argon2 — algorithms designed to be slow on purpose, so even if your database leaks, the passwords stay safe.


In plain English

If your database stored user passwords like george: "MySecret123", then anyone who got hold of a copy — a stolen backup, a SQL injection attack, a bored DBA — could log in as anyone. This was the standard in the 1990s and still happens at small startups. Every time you read “X million passwords leaked,” some of those are from places that stored passwords as text.

The fix is password hashing. You don’t store the password itself; you store a one-way transformed version of it.

  • When the user signs up, you take their password, run it through a hash function, and store the result.
  • When they log in, you take the password they just typed, run it through the same hash, and compare to what you stored.
  • The hash output looks like random garbage and can’t be reversed to recover the original password.

If your database leaks, attackers get hashes — not passwords. They’d need to guess each password, hash it, and check against the stored hash. With a good hashing algorithm, that’s prohibitively slow.

Modern password hashing uses bcrypt, argon2, or scrypt — specifically designed to be slow and memory-intensive, so that even with fast hardware, an attacker can only check ~10 passwords per second per CPU. Combined with a strong password, this is enough.

You almost never write password hashing code yourself in 2026. Supabase Auth, NextAuth, Clerk, Auth0 — every modern auth service handles this for you. But you should know how it works so you can spot bugs and make good decisions.


Why it matters

  • Database leaks happen. Backups get lost. SQL injection happens. Insiders go rogue. Cloud configs get misconfigured. If your hashing is right, leaks don’t immediately become breaches.
  • Most “password breaches” are hash cracks. When you hear “X passwords from Service Y were cracked,” that’s attackers running through guesses against leaked hashes. Better hashing → fewer cracks.
  • Common bugs are catastrophic. Storing in plain text, using fast hashes like MD5/SHA-1, missing salt — these are the auth equivalent of leaving the front door unlocked.
  • Knowing the basics helps you choose tools wisely. “Does this auth service use bcrypt with a high work factor?” is a fair question to ask.

How hashing works (conceptually)

A hash function takes input of any size and produces output of fixed size. Three key properties:

  1. Deterministic — same input always produces the same output.
  2. Fast to compute forward (input → output) — well, depends on the hash. For passwords we deliberately use slow hashes.
  3. Effectively impossible to reverse (output → input) — the only way to find input X that produces output Y is to try every X.

For a strong password hashing function, that “try every X” attack should be slow enough that even a billion-dollar attacker can’t crack a strong password in reasonable time.

Examples:

Plain password:  "MySecret123"

bcrypt hash:     $2a$12$KIXxJYqKx0Cz.GtQTrLrNeoIuQ4mZqQ4D4VfgwXjEBaRPGqgF7zKi

You can’t look at the bcrypt hash and recover MySecret123. The only attack is guessing.


Salt — making mass attacks harder

A salt is a random value added to each password before hashing:

hash("MySecret123" + "abc-def-random-salt-xyz")

The salt is unique per user and stored alongside the hash. Why this matters:

Without salts, if two users have the same password, they have the same hash. Attackers can precompute hashes for common passwords (a rainbow table) and look up the matches en masse.

With salts, the same password produces different hashes for different users. Rainbow tables don’t work. Attackers must crack each hash individually.

bcrypt, argon2, and scrypt generate and embed salts automatically. You don’t manage them separately. Don’t roll your own.


Pepper (optional extra layer)

A pepper is a secret value added to every password hash, stored separately from the database (e.g. in an environment variable). If only the database leaks, attackers still need the pepper to crack hashes.

Used by some high-security systems. Most webapps don’t bother — bcrypt + strong salt is sufficient. Pepper adds operational complexity (key rotation, separate storage) for modest benefit.


The hash algorithms — what to use, what to avoid

AlgorithmNotes
argon2idThe 2026 gold standard. Won the Password Hashing Competition. Memory-hard (resistant to GPU/ASIC attacks).
bcryptStill very widely used; well-tested; works everywhere. Choose work factor ≥ 12 in 2026.
scryptOlder but still solid. Used by some services.

❌ Avoid for passwords

AlgorithmWhy not
MD5Cryptographically broken. Fast to crack. Use only for non-security (file checksums).
SHA-1Also broken for security.
SHA-256 aloneToo fast. Even with salt, an attacker can guess billions per second on a GPU.
Plain textSelf-explanatory. Catastrophic.
Custom “encryption”Almost certainly broken. Don’t invent.

Rule of thumb: if a tutorial uses SHA-256 or MD5 to “hash passwords,” it’s wrong. Hash functions designed for password storage are intentionally slow. SHA-256 was designed to be fast — perfect for hashing files, terrible for passwords.


bcrypt in detail

bcrypt is the most common password hash in production webapps. The hash format:

$2a$12$KIXxJYqKx0Cz.GtQTrLrNeoIuQ4mZqQ4D4VfgwXjEBaRPGqgF7zKi
└┬┘ └┬┘ └────────────────────────┬────────────────────────┘
 │   │                          combined salt + hash
 │   └─ cost factor (12 = 2^12 = 4096 iterations)
 └─ algorithm version

The cost factor (or work factor) controls how slow the hashing is. Each increment doubles the work. 12 in 2026 is the typical baseline; 14 for higher security. Higher cost = slower verification = harder cracking, but also slower login for legitimate users.

A typical bcrypt verification takes ~200ms-1s. That’s a lot for a single check; it’s catastrophic for a brute-force attacker trying billions.


argon2 in detail

argon2 is the modern replacement, winner of the 2015 Password Hashing Competition. Three variants:

  • argon2d — fast, resistant to GPU attacks
  • argon2i — resistant to side-channel attacks
  • argon2id — hybrid; recommended for password hashing

argon2 is memory-hard, meaning cracking it requires lots of RAM per attempt. Modern attackers’ GPUs have huge compute but limited memory bandwidth, so argon2 hits them harder than bcrypt does.

Parameters: memory (default 64 MB), iterations (default 3), parallelism (default 4). Tune for your hardware.

If you’re building from scratch in 2026, argon2id is the recommendation. bcrypt is fine for existing systems.


A concrete example: bcrypt in Node.js

import bcrypt from "bcrypt";
 
// At signup
const password = "MySecret123";
const saltRounds = 12;
const hash = await bcrypt.hash(password, saltRounds);
// Store `hash` in your DB. NOT the password.
 
// At login
const candidatePassword = "MySecret123";
const storedHash = await db.users.findUnique({ where: { id } }).then(u => u.password_hash);
const isValid = await bcrypt.compare(candidatePassword, storedHash);
if (isValid) {
  // grant session
}

Salt generation, hash, and verification — all wrapped in two function calls. Don’t try to do this manually.

Supabase, Clerk, and similar handle this internally — you never see the password or the hash.


Password strength — the other half

Even perfect hashing can’t save a weak password. If George’s password is “password” or “123456” or his pet’s name from his public Instagram, attackers can guess.

Don’t impose silly rules (“must contain a number, capital, special character”). Research shows these often produce weaker passwords (Password1! everywhere) than longer simpler ones.

Do:

  • Require a minimum length (12+ characters)
  • Block known-breached passwords (check against Have I Been Pwned API)
  • Encourage passphrases (“correct horse battery staple”)
  • Offer MFA
  • Support password managers (don’t disable copy/paste)

NIST’s modern guidance (SP 800-63B) is the canonical reference.


What “salting + peppering + bcrypt + MFA” buys you

A scenario: your database leaks. What happens?

DefenseCracking effort for attacker
Plain textZero. Done.
MD5, no saltSeconds. Rainbow tables.
SHA-256, no saltHours to days. Rainbow tables on GPU.
SHA-256 + saltDays to weeks per strong password.
bcrypt cost 10 + saltWeeks to months per strong password.
bcrypt cost 12 + saltMonths to years per strong password.
argon2id + saltYears to decades per strong password (memory-hard).
argon2id + salt + MFA enabledEven if cracked, attacker still needs second factor.

Higher = safer. The bar in 2026 is bcrypt 12+ or argon2id; offer MFA; block weak passwords.


Migration: upgrading hashes

Suppose you’ve been using MD5 (gasp). How to migrate to bcrypt?

You can’t directly — you don’t know the original passwords. The pattern:

  1. Keep accepting logins with the old hash.
  2. On successful login (you now know the password), re-hash with bcrypt and update.
  3. Over time, all active users migrate.
  4. Inactive users → force a password reset eventually.

Same pattern for bcrypt cost factor upgrades (e.g. 10 → 12): on login, check the cost; if old, re-hash with new cost.

Most auth libraries support this transparently.


Common gotchas

  • Storing passwords in plain text. Catastrophic. If your code has users.password = req.body.password, you have a critical bug. Use a hashing library.

  • Using fast hashes (MD5, SHA-256) for passwords. Fast hashing was the right tool for the wrong job. Use bcrypt, argon2, or scrypt.

  • Implementing your own hashing. Almost certainly insecure. Use a library written by cryptographers.

  • Same salt for every user (or no salt). Salts must be unique per user; modern libraries handle this.

  • Storing salt or pepper in the same database as hashes. Salt is fine (it’s per-user random). Pepper, if used, should be elsewhere.

  • Logging the password. Application logs accidentally contain passwords. Triple-check what you log.

  • Comparing hashes with == (string compare). Modern bcrypt comparison uses constant-time comparison. The library handles this; if you roll your own, use a constant-time compare function.

  • Returning detailed errors. “User not found” vs “Wrong password” leaks which emails are registered. Return generic “Login failed” for both.

  • Allowing very short passwords. Anything under 8 characters is trivial to crack; modern recommendation is 12+ characters.

  • Allowing copy/paste blocking. Password managers are GOOD for security. Sites that disable paste are actively harmful.

  • Not rate-limiting login attempts. Bcrypt takes 200ms per check — slow for normal users, fast enough that an attacker can try thousands. Add rate limiting.

  • Re-using compromised passwords across services. Not your code’s bug, but: check against known breaches; warn users.

  • Storing security questions in plain text. “What’s your mother’s maiden name?” — these are passwords by another name. Hash or skip them.

  • Resetting password to a known value. “We’ve reset your password to ‘temp123’” sent over email. Use one-time reset links instead.

  • Password reset that doesn’t expire links. A reset link valid forever is a backdoor. Expire in 30-60 minutes.

  • Forgetting to invalidate sessions on password change. User changes password (maybe because they suspect compromise). Old session still works. Invalidate.

  • Cost factor too low / too high. Too low = easy to crack. Too high = slow login + DOS risk. Aim for ~250-500ms verification on your hardware.

  • Trusting client-side hashing. Browser hashes password before sending? Now the hash IS the password — attackers don’t need to crack anything. Hash on the server.


See also

Sources