Password Security
Hashing, Not Encrypting
Passwords must be hashed, never encrypted and never stored in plaintext. The distinction matters: encryption is reversible (anyone with the key can decrypt), hashing is one-way (you cannot recover the original password from the hash).
When a user logs in, you hash their input and compare it to the stored hash. You never need to know the actual password.
Wrong: Storing plaintext
database: { user: "alice", password: "correct-horse-battery-staple" }
Wrong: Encrypting passwords
database: { user: "alice", password: "aGVsbG8gd29ybGQ=" }
(anyone with the encryption key can decrypt all passwords at once)
Right: Hashing passwords
database: { user: "alice", password: "$2b$12$LJ3m4ys..." }
(cannot be reversed, even by the system administrator)
bcrypt & Argon2id
bcrypt
bcrypt has been the industry standard for password hashing since 1999. It is deliberately slow, includes a built-in salt, and has a configurable work factor.
bcrypt output format:
$2b$12$LJ3m4ysR5PmFXjGZ1aWkhe0AhMdHGIVb5R5tHxPozUMnHqKWPkXCC
| | | |
| | salt (22 chars) hash (31 chars)
| cost factor (2^12 = 4096 iterations)
algorithm version
The salt is generated automatically and stored as part of the hash.
You do not need to manage salts separately.
Cost factor guidance:
Factor 10: ~100ms on modern hardware (minimum acceptable)
Factor 12: ~300ms (good default for most applications)
Factor 14: ~1s (high-security applications)
Increase the cost factor as hardware gets faster.
Target: at least 250ms per hash on your production hardware.
Argon2id
Argon2id won the Password Hashing Competition in 2015 and is the recommended algorithm for new projects. It is memory-hard, meaning it requires significant RAM to compute, making it resistant to GPU and ASIC attacks.
Argon2id parameters:
- Memory cost: 64 MB minimum (OWASP recommends 19 MiB for Argon2id)
- Time cost: 3 iterations minimum
- Parallelism: 1 (for password hashing)
Why memory-hard matters:
GPUs have many cores but limited memory per core.
bcrypt at cost 12 can be parallelized across GPU cores.
Argon2id with 64MB memory requirement means each attempt
needs 64MB of RAM, severely limiting parallel attacks.
What Never to Use
MD5:
- Designed for file integrity, not passwords
- Crackable at ~60 billion hashes/second on modern GPU
- A 6-character alphanumeric password: cracked in under 1 second
- Known collision vulnerabilities since 2004
SHA1:
- First collision demonstrated in 2017 (SHAttered)
- Still too fast for password hashing (~20 billion hashes/sec)
- Deprecated for certificates since 2017
SHA256:
- Cryptographically strong for integrity checking
- Too fast for password hashing (~8 billion hashes/sec on GPU)
- No built-in salt or cost factor
Plaintext:
- Real example: 2019 Facebook stored hundreds of millions
of passwords in plaintext in internal logs
- Accessible to thousands of engineers
- No technical excuse exists for this in any era
Salting
A salt is a random value added to each password before hashing. It ensures that identical passwords produce different hashes.
Without salt:
hash("password123") = "ef92b778..." (always the same)
If two users have "password123", their hashes are identical.
Attackers can precompute tables (rainbow tables) of common
password hashes and look up matches instantly.
With salt:
hash("password123" + "a9f3b2c1") = "7d3e4f1a..."
hash("password123" + "e8d7c6b5") = "2b1a9c8d..."
Same password, different hashes. Rainbow tables are useless.
bcrypt and Argon2id generate and embed salts automatically. If you are using these algorithms through a standard library, you do not need to manage salts yourself. This is one reason to use established libraries rather than building your own hashing.
Password Requirements
Length Over Complexity
NIST SP 800-63B (2017) changed industry guidance on password requirements. Complexity rules (uppercase, lowercase, number, special character) do not improve security and actively harm it.
Old approach (pre-2017 NIST):
- Minimum 8 characters
- Must include uppercase, lowercase, number, special character
- Must change every 90 days
- Cannot reuse last 12 passwords
What users do:
P@ssw0rd1! -> P@ssw0rd2! -> P@ssw0rd3!
Current approach (NIST SP 800-63B):
- Minimum 8 characters (15+ recommended)
- No complexity requirements
- No mandatory rotation unless compromise is suspected
- Check against breached password lists
- Allow all printable characters including spaces
- Allow paste (support password managers)
- Maximum length at least 64 characters
Why length wins:
8-char with complexity: 95^8 = 6.6 quadrillion combinations
16-char lowercase only: 26^16 = 43.6 sextillion combinations
The longer password is 6.6 million times harder to brute-force,
and "correct horse battery staple" is far easier to remember
than "P@55w0rD".
Breached Password Checks
Check new passwords against known breached password databases at registration and password change time.
Have I Been Pwned (HIBP) API:
1. Hash the password with SHA1
2. Send first 5 characters of the hash to the API
3. API returns all hashes matching that prefix
4. Check locally if the full hash appears in the response
This is a k-anonymity approach — the full password hash
is never sent to the API. Your user's password remains private.
Example flow:
Password: "P@ssw0rd"
SHA1: "21BD12DC183F740EE76F27B78EB39C8AD972A757"
Send to API: "21BD1"
API returns ~500 hash suffixes with breach counts
Check if "2DC183F740EE76F27B78EB39C8AD972A757" is in the list
It is, with count 52,579 — reject this password
Rate Limiting Login Attempts
Without rate limiting, attackers can try millions of passwords against your login endpoint.
Rate limiting strategies:
Per-IP rate limiting:
- Allow 10 attempts per IP per minute
- After limit: return 429 Too Many Requests
- Problem: attackers use distributed IPs (botnets)
Per-account rate limiting:
- Allow 5 failed attempts per account per 15 minutes
- After limit: require CAPTCHA or progressive delay
- Problem: can be used for denial-of-service against specific accounts
Combined approach (recommended):
- Per-IP: 20 attempts per minute across all accounts
- Per-account: 5 failures before progressive delay
- Progressive delay: 1s, 2s, 4s, 8s, 16s... between attempts
- After 10 failures: require additional verification (CAPTCHA, email)
- Never permanently lock the account (enables DoS)
Account Lockout vs Progressive Delays
Hard lockout (bad):
- Lock account after 5 failed attempts
- Require admin or email to unlock
- Problem: Attacker can lock any account they know the username for
- Denial-of-service against targeted users (executives, admins)
Progressive delays (better):
- 1st failure: immediate
- 2nd failure: 1 second delay
- 3rd failure: 2 second delay
- 5th failure: 8 second delay
- 10th failure: 60 second delay + require CAPTCHA
- Slows brute-force to a crawl without locking legitimate users out
- Account remains accessible, just slower
Real example: Apple iCloud 2014 ("The Fappening")
- Attackers brute-forced iCloud credentials via Find My iPhone API
- No rate limiting on that specific API endpoint
- Unlimited login attempts allowed
- Celebrity photos stolen and leaked
- Apple added rate limiting after the incident
Password Reset Flow
Password reset is an alternative authentication mechanism. If it is weaker than the primary login, it becomes the attack vector.
Secure password reset flow:
1. User requests reset via email address
2. Application ALWAYS responds "If that email exists, we sent a link"
(never confirm whether the email is registered)
3. Generate a cryptographically random token (at least 32 bytes)
4. Store the token hash (not the token itself) with an expiry
5. Send the token in a one-time-use link via email
6. Token expires after 15-60 minutes
7. On use: verify token hash, check expiry, require new password
8. Invalidate the token after use
9. Invalidate all other sessions for the account
10. Notify the user that their password was changed
Password reset anti-patterns:
- Sending the new password in the email (visible to email admins, logs)
- Sending the old password (means you stored it reversibly)
- Using predictable tokens (sequential IDs, timestamps)
- Tokens that never expire
- Tokens that can be reused
- Security questions as the sole reset mechanism
- Not invalidating the token after use
- Not invalidating existing sessions after password change
Real example: 2012 GitHub password reset vulnerability
- Reset tokens were generated using a predictable algorithm
- Attacker could predict valid reset tokens
- Could reset any user's password
- Fixed by switching to cryptographically random token generation
Credential Storage Architecture
Production credential handling:
User registration:
1. Validate password (length, breached check)
2. Generate hash: argon2id(password, auto_salt, memory=64MB, time=3)
3. Store only the hash string in the database
4. Never log the password, even temporarily
User login:
1. Retrieve hash from database by username/email
2. Verify: argon2id_verify(stored_hash, provided_password)
3. If valid: create session, regenerate session ID
4. If invalid: increment failure counter, apply rate limiting
5. Log the attempt (success or failure, never the password)
Password change:
1. Verify current password first
2. Validate new password (length, breached check, not same as old)
3. Hash new password
4. Update database
5. Invalidate all other sessions
6. Notify user via email
Common Pitfalls
-
Using SHA256 "because it's strong": SHA256 is a strong hash for data integrity, but its speed makes it unsuitable for passwords. An attacker with a modern GPU can compute billions of SHA256 hashes per second. Use bcrypt or Argon2id.
-
Adding pepper without understanding the trade-off: A pepper (server-side secret added to all passwords before hashing) adds a layer of protection but complicates key management. If you lose the pepper, all users must reset their passwords. If you use one, store it in a HSM or key vault, not in your application config.
-
Not upgrading legacy hashes: If you inherit a system using MD5 or SHA1 hashes, re-hash passwords transparently on next login. Verify with the old algorithm, then hash with bcrypt/Argon2id and update the database.
-
Allowing unlimited password length: While you should support long passwords, passwords over a few thousand characters can cause denial-of-service (hashing a 1MB password takes significant CPU). Set a maximum of 64-128 characters.
-
Confirming email existence on reset: "No account found with that email" tells attackers which emails are registered. Always respond with the same message regardless of whether the account exists.
-
Not invalidating sessions on password change: If an attacker has an active session and the user changes their password, the attacker keeps access unless all existing sessions are invalidated.
Key Takeaways
- Use bcrypt (cost 12+) or Argon2id (64MB+ memory) for password hashing. Never MD5, SHA1, SHA256, or plaintext. Speed is the enemy.
- Salt is built into bcrypt and Argon2id. You do not need to manage it separately if you use standard libraries.
- Enforce password length (12+ characters), not complexity. Check passwords against breached databases (HIBP). Do not require periodic rotation.
- Use progressive delays, not hard account lockout. Hard lockout enables denial-of-service against specific accounts.
- Password reset tokens must be cryptographically random, time-limited, single-use, and stored as hashes. Never send passwords in email.
- Rate limit login attempts by both IP and account. Without rate limiting, brute-force attacks are trivial.