Learn why password hashing matters, why MD5 is broken for security, how SHA-256 differs, and why bcrypt and Argon2 are the right choice for storing passwords. Understand rainbow tables, salting, and modern best practices.
The database breach that taught me everything
Years ago, I inherited a project where passwords were stored in plain text. PLAIN TEXT. When I saw the users table, my stomach dropped. Thousands of passwords, just sitting there readable by anyone with database access. I spent the next weekend migrating to bcrypt and praying we hadn't been compromised yet.
Hashing is not encryption - this distinction matters. Encryption is reversible with a key. Hashing is intentionally one-way. You verify passwords by hashing the input and comparing, but you can never recover the original. This is exactly what we want for password storage.
Hash functions 101
A hash function takes any input and produces fixed-length output. The same input always gives the same output, but change one character and the result is completely different. We call this the avalanche effect.
- Deterministic: Same input = same hash, every time
- Fixed output: SHA-256 always gives you 64 hex characters
- One-way: You cannot reverse a hash back to the input
- Avalanche effect: "password" and "password1" produce completely different hashes
- Collision-resistant: Finding two inputs with the same hash is practically impossible
- Fast to compute: This is actually a problem for password hashing (more on that later)
MD5: please stop using this
I still find MD5 password hashes in legacy codebases. Every time, I have the same conversation: MD5 is broken. It was created in 1991 and is completely unsuitable for security in 2024.
- First collision attack was way back in 2004
- Modern hardware generates collisions in seconds
- Rainbow tables for MD5 cover literally billions of passwords
- 128 bits is just too short for modern security
- Fine for checksums and non-security stuff
- NEVER for passwords, signatures, or certificates
- If your app uses MD5 for passwords, migration is urgent
SHA-1: also deprecated
SHA-1 produces a 160-bit hash and was the standard from 1995 until Google's SHAttered attack in 2017. They created two different PDFs with the same SHA-1 hash. Game over.
- Theoretical attacks started appearing in 2005
- SHAttered in 2017 proved practical collision attacks
- Major browsers stopped accepting SHA-1 certificates that same year
- Git still uses SHA-1 (they're working on SHA-256 migration)
- Don't use SHA-1 for anything security-related
- Marginally better than MD5, but that's a low bar
- Legacy systems using SHA-1 need migration plans
SHA-2: the secure standard
SHA-2 is a family of hash functions from NIST (2001). SHA-256 and SHA-512 are the ones you'll actually use. No practical attacks exist against them.
- SHA-256: 256-bit output, used by Bitcoin and TLS certificates
- SHA-512: 512-bit, actually faster on 64-bit CPUs
- SHA-384: Truncated SHA-512, niche use cases
- SHA-224: Truncated SHA-256, rarely seen in practice
- No known collision attacks
- Great for signatures, certificates, data integrity
- But still too fast for password hashing alone
Why SHA-256 isn't enough for passwords
SHA-256 is cryptographically solid. But here's the problem: it's WAY too fast. A single GPU can compute over 10 billion SHA-256 hashes per second. That's a problem when you're trying to slow down attackers.
- GPU: 10+ billion hashes per second
- Eight-character passwords? Hours to brute-force.
- Password dictionaries have millions of common passwords
- Rainbow tables precompute hashes for instant lookup
- Speed is great for file verification. Terrible for passwords.
- Password-specific algorithms intentionally slow things down
- Work factors let you adjust difficulty as hardware improves
Rainbow tables: the reason we salt
Rainbow tables are precomputed lookup tables mapping hashes to original inputs. Instead of computing during an attack, you just look up the hash. Unsalted password databases become trivial to crack.
- Tables cover billions of password-to-hash mappings
- Lookup is instant vs computing in real-time
- Tables exist for MD5, SHA-1, common SHA-256 patterns
- Longer passwords and bigger character sets make tables impractical
- Salting defeats rainbow tables completely
- It's a time-memory tradeoff: storage for speed
- Modern tables use chain reduction to compress storage
Salting: the simple fix that works
A salt is random data added to each password before hashing. Identical passwords get different hashes. Rainbow tables become useless. Salts don't need to be secret - they just need to be unique per user.
- Generate a cryptographically random salt for EACH password
- Minimum: 16 bytes (128 bits)
- Store salt alongside the hash - it's not a secret
- Same password + different salt = completely different hash
- Forces attackers to attack each hash individually
- You can't tell if two users have the same password
- Salt + slow algorithm = solid defense
bcrypt: what I use for most projects
bcrypt was designed for password hashing back in 1999 and it's still excellent. Built-in salt, configurable work factor, widely supported. This is my default choice.
- Based on Blowfish cipher with expensive key setup
- Work factor (cost) is power of 2: cost 10 = 2^10 iterations
- Salt generation and storage built into the hash string
- Output: $2b$[cost]$[22-char salt][31-char hash]
- Resistant to GPU acceleration due to memory requirements
- I use cost 12-14, depending on server specs
- Supported in basically every language
Argon2: the newer, fancier option
Argon2 won the Password Hashing Competition in 2015. If I'm starting a new project and have the choice, I'll use Argon2id. Configurable memory, time, and parallelism - it's designed to resist both GPU and ASIC attacks.
- Three variants: Argon2d (GPU-resistant), Argon2i (side-channel resistant), Argon2id (hybrid)
- Use Argon2id for password hashing
- Memory-hard: Requires significant RAM, defeats parallel attacks
- Configurable time cost, memory cost, parallelism
- OWASP suggests: 64 MB memory, 3 iterations, 4 parallelism
- Parameters can be tuned as hardware improves
- Support is growing but not as universal as bcrypt yet
scrypt: the middle ground
scrypt came out in 2009, designed to be memory-hard. Litecoin uses it. It's between bcrypt and Argon2 in features and adoption.
- Memory-hard design requires significant RAM per hash
- Used by Litecoin and other cryptocurrencies
- Parameters: N (CPU/memory), r (block size), p (parallelism)
- Harder to configure correctly than bcrypt or Argon2
- Less side-channel resistant than Argon2i
- Good choice if Argon2 isn't available
- Being replaced by Argon2 in new projects
What I actually recommend
Here's my decision tree for password hashing:
- New project: Argon2id with OWASP parameters
- Existing app with bcrypt: Keep it, use cost 12+
- Legacy MD5/SHA: Migrate ASAP, rehash on login
- Never: MD5, SHA-1, or plain SHA-256 for passwords
- Consider: Hardware constraints, library support, compliance
- Test: Measure hash time on your actual production hardware
- Revisit: Bump work factors every 18-24 months
Implementation rules I follow
Getting the algorithm right is only half the battle. Implementation details matter just as much.
- Use established libraries. Never roll your own crypto.
- Generate salts with cryptographically secure RNG
- Store full hash output including algorithm ID and parameters
- Use constant-time comparison to prevent timing attacks
- Add rate limiting against brute-force login attempts
- Log failed auth attempts for security monitoring
- Never truncate hashes or do partial comparisons
Libraries I use
Every major language has solid password hashing libraries. Use them.
- Node.js: bcrypt or argon2 npm packages
- Python: passlib, bcrypt, argon2-cffi
- PHP: password_hash() with PASSWORD_BCRYPT or PASSWORD_ARGON2ID
- Java: Spring Security, jBCrypt, Argon2-jvm
- Go: golang.org/x/crypto/bcrypt, alexedwards/argon2id
- Ruby: bcrypt-ruby, argon2
- C#: BCrypt.Net-Next, Konscious.Security.Cryptography
Migrating from weak hashes
Inherited an MD5 or unsalted SHA codebase? Here's how I handle migrations:
- Add a new column for the upgraded hash
- When user logs in, verify against old hash
- If valid, rehash with bcrypt/Argon2 immediately
- Store new hash, clear old one
- Optionally: Force password resets for dormant accounts
- Never try to "decrypt" hashes - that's not how hashing works
- Track migration progress, set deadline for removing old hashes
Mistakes I see constantly
Password hashing seems simple. It's not. I catch these mistakes in code reviews all the time:
- Using fast hashes (MD5, SHA-256) without password-specific algorithm
- Same salt for all users (defeats the entire purpose)
- Storing salts separately (doesn't add security, just complexity)
- Short or predictable salts
- Homegrown hashing schemes (please don't)
- Using encryption instead of hashing (encryption is reversible!)
- Work factors too low (should take 100-500ms)
- Never increasing work factors as hardware improves
Hashing beyond passwords
While this post is about passwords, hash functions are everywhere in my work:
- File integrity: Checksums to verify nothing changed
- Digital signatures: Hash the message before signing
- Blockchain: SHA-256 powers Bitcoin
- API auth: HMAC for request signing
- Git: SHA-1 for commit hashes (moving to SHA-256)
- Deduplication: Identify identical files without full comparison
- Cache keys: Generate consistent keys from request params
The bottom line
Password hashing protects your users when (not if) your database gets breached. Use Argon2id for new projects, bcrypt for existing systems. Never MD5, never SHA-1, never plain SHA-256.
Hashing is not encryption. Salts must be unique, not secret. Work factors need regular review. Play with the Hash Generator tool on CodeUtil to see how these algorithms differ, but always use proper password hashing libraries in production code.
FAQ
What is the difference between hashing and encryption?
Hashing is one-way: you cannot recover the original data from a hash. Encryption is two-way: with the correct key, you can decrypt data back to its original form. Passwords should be hashed (not encrypted) because you never need to retrieve the original password—only verify that a user knows it.
Why is MD5 considered insecure?
MD5 has known collision vulnerabilities that allow attackers to create different inputs with the same hash. More importantly for passwords, MD5 is extremely fast (billions of hashes per second on GPUs), enabling rapid brute-force attacks. Rainbow tables for MD5 are widely available and cover most common passwords.
What is a salt and why does it matter?
A salt is a random value added to each password before hashing. It ensures identical passwords produce different hashes, defeating precomputed rainbow tables and preventing attackers from identifying users with the same password. Salts must be unique per user but do not need to be secret.
Should I use bcrypt or Argon2?
For new applications, use Argon2id—it won the Password Hashing Competition and provides the strongest security with configurable memory, time, and parallelism costs. For existing applications already using bcrypt with adequate work factors (12+), there is no urgent need to migrate. Both are secure when properly configured.
How long should password hashing take?
Password hashing should take between 100 and 500 milliseconds on your production hardware. This is slow enough to prevent brute-force attacks but fast enough not to impact user experience. Adjust the work factor (iterations, memory) to achieve this target, and retest periodically as hardware improves.
Can I use SHA-256 for password hashing?
Plain SHA-256 is not suitable for password hashing because it is too fast. A GPU can compute billions of SHA-256 hashes per second, making brute-force attacks practical. If you must use SHA-256, apply it through PBKDF2 with at least 310,000 iterations (OWASP 2023 recommendation), but bcrypt or Argon2 are better choices.
What happens if my database with hashed passwords is stolen?
With properly hashed passwords (bcrypt/Argon2 with adequate work factors), attackers cannot easily recover the original passwords. They would need to attempt brute-force attacks, which are computationally expensive. However, users with weak passwords are still at risk, which is why password policies and breach notification matter.
How do I migrate from MD5 to a secure algorithm?
The safest migration is transparent rehashing: when a user logs in, verify their password against the old MD5 hash, then immediately rehash it with bcrypt or Argon2 and store the new hash. For inactive accounts, consider requiring password resets. Never try to decrypt or reverse existing hashes—this is mathematically impossible.