Today’s Lesson
Security for Legal SaaS — Episode 17: Password Hashing Done Right
Why “Encrypted Passwords” Is the Wrong Answer
When someone says their passwords are stored “encrypted,” alarm bells should ring. Encryption is reversible — if you can decrypt, so can an attacker who obtains your key. The 2012 LinkedIn breach exposed 117 million passwords hashed with unsalted SHA-1 — a hash function designed for speed, not resistance. Within days, the majority were cracked.
Key distinction: Hashing is a one-way function — you cannot reverse it to obtain the original password. Encryption is two-way — anyone with the key can recover the plaintext. Password storage requires hashing, never encryption. OWASP’s Password Storage Cheat Sheet is unambiguous on this point.
For legal SaaS platforms, a password database breach has compounding consequences. Lawyers reuse passwords across systems — a 2023 Bitwarden survey found 68% of internet users manage passwords for 10+ sites, with significant reuse. A cracked password from your platform becomes a credential stuffing weapon against court filing systems, client portals, and bar association accounts.
The Modern Password Hashing Algorithms
Argon2 — The Current Standard
Argon2 won the Password Hashing Competition in 2015, selected from 24 submissions by a panel of cryptographers. It comes in three variants:
| Variant | Optimised Against | Use Case |
|---|---|---|
| Argon2id | Both GPU and side-channel attacks | Recommended default |
| Argon2i | Side-channel attacks | When timing attacks are the primary threat |
| Argon2d | GPU cracking | When side-channels are not a concern |
OWASP recommends Argon2id with these minimum parameters:
- Memory: 19 MiB (19456 KiB)
- Iterations: 2
- Parallelism: 1
The key insight is memory-hardness. Unlike bcrypt or PBKDF2, Argon2 requires a configurable amount of RAM per hash computation. GPUs have limited per-core memory — this makes massively parallel cracking economically infeasible.
bcrypt — Battle-Tested but Aging
bcrypt (1999) introduced the concept of an adaptive cost factor. Each increment doubles the computation time. It’s been the workhorse of password hashing for 25 years.
| Cost Factor | Approximate Time (2024 hardware) | Suitable For |
|---|---|---|
| 10 | ~100ms | Development/testing |
| 12 | ~400ms | Production minimum |
| 14 | ~1.6s | High-security applications |
Limitations: bcrypt truncates input at 72 bytes. Passwords longer than 72 characters are silently truncated — which means a 100-character passphrase provides no more security than its first 72 characters. It also cannot leverage more than a fixed amount of memory, making it less resistant to modern GPU attacks than Argon2.
scrypt — Memory-Hard Pioneer
scrypt (2009) introduced memory-hardness before Argon2. Designed by Colin Percival for the Tarsnap backup service. It’s still a solid choice, but Argon2id is preferred because scrypt’s memory-hardness parameter (N) is less granular and it lacks Argon2’s hybrid resistance to both GPU and side-channel attacks.
Salts — Why They’re Non-Negotiable
A salt is a random value prepended to the password before hashing, unique per user. Without salts:
- Identical passwords produce identical hashes (rainbow table attacks)
- An attacker can precompute hashes for common passwords and compare them against your entire database simultaneously
- The RockYou breach (2009) stored 32 million passwords in plaintext — the published list became the foundation for every password cracking dictionary since
Requirements:
- Cryptographically random (use
os.urandom()or/dev/urandom, neverMath.random()) - Minimum 16 bytes (128 bits)
- Unique per user, per password change
- Stored alongside the hash (this is not a secret — its purpose is to defeat precomputation)
Modern algorithms (bcrypt, scrypt, Argon2) generate and embed salts automatically. If you’re implementing salt management manually, you’re probably using the wrong library.
Timing Attacks and Constant-Time Comparison
When verifying a password, naive string comparison (==) leaks information through timing. If the comparison fails on the first byte, it returns faster than if it fails on the last byte. An attacker making thousands of requests can statistically determine the correct hash byte-by-byte.
The defence is constant-time comparison — functions that always take the same amount of time regardless of where the mismatch occurs. Every major framework provides this:
- Python:
hmac.compare_digest() - Node.js:
crypto.timingSafeEqual() - Go:
subtle.ConstantTimeCompare() - Java:
MessageDigest.isEqual()
Implementation note: Even with constant-time comparison, the hashing step itself has variable timing based on the input. This is acceptable — the timing variation reveals nothing about the stored hash, only about the submitted password (which the attacker already knows).
Password Policies — Length Over Complexity
NIST Special Publication 800-63B (Digital Identity Guidelines) overturned decades of conventional wisdom in 2017:
| Old Policy (Deprecated) | NIST 800-63B Recommendation |
|---|---|
| Minimum 8 characters with uppercase, lowercase, number, symbol | Minimum 8 characters (15+ recommended), no composition rules |
| Forced rotation every 90 days | No periodic rotation unless compromise evidence |
| Security questions for recovery | Prohibited (answers are guessable/social-engineerable) |
| Password hints | Prohibited |
Why? Composition rules produce predictable patterns (P@ssw0rd1!). Forced rotation produces incremental changes (Summer2024! → Autumn2024!). Length provides exponential entropy growth — a 20-character passphrase of random words defeats any brute-force attack regardless of character composition.
NCSC (UK National Cyber Security Centre) guidance aligns: “Help users cope with password overload” — allow password managers, stop penalising length, stop requiring rotation.
Credential Stuffing Defence
Credential stuffing attacks use leaked username/password pairs from other breaches against your platform. The haveibeenpwned database contains over 12 billion compromised accounts.
Defence Layers
| Layer | Mechanism | Effectiveness |
|---|---|---|
| Compromised password check | Check against haveibeenpwned API (k-anonymity model) at registration and login | Blocks known-compromised passwords |
| Rate limiting | Maximum 5 failed attempts per account per 15 minutes | Slows automated attacks |
| IP reputation | Block/challenge requests from known botnet IPs | Reduces attack volume |
| Device fingerprinting | Challenge logins from unrecognised devices | Detects credential stuffing from new origins |
| CAPTCHA on threshold | Trigger after 3 failures | Blocks automated tooling |
Troy Hunt’s haveibeenpwned Pwned Passwords API uses k-anonymity — you send only the first 5 characters of the SHA-1 hash of the password, receive all matching suffixes, and check locally. The full password never leaves your server. NIST 800-63B specifically requires checking passwords against known-compromised lists.
Implementation Checklist for Legal SaaS
Production checklist:
- Use Argon2id (preferred) or bcrypt with cost factor ≥12
- Never implement your own hashing — use established libraries (
argon2-cffi,bcrypt,passlib) - Check passwords against haveibeenpwned at registration and periodic login
- Enforce minimum 12 characters, no maximum below 128, no composition rules
- Allow and encourage paste (password managers rely on it)
- No forced rotation without evidence of compromise
- Constant-time comparison for all credential verification
- Log authentication failures with IP/user-agent (but NEVER log the attempted password)
- Rate limit failed attempts per account and per IP
The Dropbox breach (2012, disclosed 2016) exposed 68 million bcrypt hashes. Despite the breach, bcrypt’s work factor meant mass cracking was economically impractical. Proper hashing doesn’t prevent breaches — it ensures breached data is useless.
Work Factor Calibration
Your hashing work factor should be calibrated to your hardware, targeting 200–500ms per hash on your authentication servers. OWASP’s guidance: “Err on the side of longer computation time.”
Recalibrate annually as hardware improves. When you increase the work factor, rehash existing passwords on next successful login — verify against the old hash, then silently upgrade to the new parameters. The user never notices; your security improves continuously.
Conclusion
Password hashing is a solved problem — but only if you use the solution. Argon2id with appropriate memory and iteration parameters. Salts generated automatically. Constant-time verification. Length-based policies without composition rules. Compromised credential checking via haveibeenpwned. And the work factor recalibrated annually.
The cost of getting this right is one afternoon of implementation. The cost of getting it wrong is 117 million cracked passwords and a breach notification to every client your platform serves.