Password storage is one of the most critical security responsibilities for any application handling user accounts. A single breach exposing poorly-stored passwords can affect millions of users across many services (since people reuse passwords). This guide covers modern password hashing techniques and secure authentication practices.
Why Hashing Matters
Passwords should never be stored in plain text or with reversible encryption. When a database breach occurs;and breaches are increasingly common;properly hashed passwords remain protected, while plain text or weakly hashed passwords are immediately compromised.
The Threat Model
When attackers obtain a password database, they typically attempt:
- Rainbow table attacks: Pre-computed hash lookups
- Dictionary attacks: Trying common passwords
- Brute force attacks: Trying all combinations
- Credential stuffing: Using leaked credentials from other breaches
Password Hashing Fundamentals
What Makes a Good Password Hash?
- One-way: Cannot be reversed to obtain the original password
- Deterministic: Same input always produces same output
- Slow (computationally expensive): Makes brute force impractical
- Salt-inclusive: Prevents rainbow table attacks
- Configurable cost: Can be adjusted as hardware improves
Why Not MD5/SHA-1/SHA-256?
These are cryptographic hash functions, not password hashing functions. They're designed to be fast;exactly what you don't want for passwords. Consider the performance difference on modern hardware:
MD5: ~10 billion hashes/second on modern GPU
bcrypt: ~10 thousand hashes/second on same hardware
You can see the difference is staggering - a million-fold reduction in attack speed. The speed difference makes brute force attacks millions of times harder with proper password hashing. What takes seconds to crack with MD5 could take years with bcrypt.
Modern Password Hashing Algorithms
Argon2 (Recommended)
Winner of the Password Hashing Competition (PHC) in 2015. It's the current gold standard.
Variants:
- Argon2id: Recommended for most uses (hybrid of below)
- Argon2i: Resistant to side-channel attacks
- Argon2d: Resistant to GPU cracking
PHP Implementation:
PHP 7.2+ includes native Argon2 support through the password_hash function. The API is intentionally simple to reduce the chance of implementation errors:
// Hash a password
$hash = password_hash($password, PASSWORD_ARGON2ID);
// Verify a password
if (password_verify($inputPassword, $storedHash)) {
// Password is correct
}
Notice that you don't need to handle salts or store configuration parameters separately. Everything needed to verify the hash is encoded in the output string.
Node.js with argon2:
The argon2 package provides a clean async API that works well with modern JavaScript:
const argon2 = require('argon2');
// Hash
const hash = await argon2.hash(password);
// Verify
const valid = await argon2.verify(hash, inputPassword);
Python with argon2-cffi:
Python's argon2-cffi package provides a high-level interface that handles the complexity for you:
from argon2 import PasswordHasher
ph = PasswordHasher()
hash = ph.hash(password)
# Verify
try:
ph.verify(hash, input_password)
except VerifyMismatchError:
# Invalid password
The exception-based verification pattern makes it harder to accidentally mishandle failed login attempts.
bcrypt
The long-standing industry standard. Still excellent when Argon2 isn't available, and many existing systems use it successfully.
PHP: Laravel and most PHP frameworks default to bcrypt. The Laravel Hash facade provides a convenient wrapper:
// Hash with bcrypt (default in many PHP versions)
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// Laravel handles this automatically
$hash = Hash::make($password);
if (Hash::check($inputPassword, $hash)) {
// Valid
}
The cost parameter controls how computationally expensive the hash is. Each increment doubles the time required.
Node.js: The bcrypt package is one of the most battle-tested options for Node.js applications:
const bcrypt = require('bcrypt');
const saltRounds = 12;
const hash = await bcrypt.hash(password, saltRounds);
const valid = await bcrypt.compare(inputPassword, hash);
Limitations:
- 72-byte password limit (passwords longer than this are truncated)
- Can't configure memory usage (only CPU cost)
Keep these limitations in mind, but for most applications they won't be an issue. If you need to support very long passphrases or want memory-hard protection, consider Argon2 instead.
scrypt
Memory-hard function that's harder to parallelize on GPUs. This makes it particularly effective against attackers using specialized hardware.
The following example shows how to use Node's built-in crypto module for scrypt hashing:
const crypto = require('crypto');
const hash = crypto.scryptSync(password, salt, 64, {
N: 16384, // CPU/memory cost
r: 8, // Block size
p: 1 // Parallelization
});
Note that unlike bcrypt and Argon2 implementations shown above, scrypt requires you to manage the salt separately. Consider using a higher-level wrapper if your platform provides one.
Implementation Best Practices
Let the Library Handle Salts
Modern password hashing libraries generate and store salts automatically. Never implement your own salt handling since it's easy to get wrong in subtle ways.
// WRONG - manual salt
$salt = random_bytes(16);
$hash = hash('sha256', $salt . $password);
// RIGHT - library handles everything
$hash = password_hash($password, PASSWORD_ARGON2ID);
The first approach requires you to correctly store the salt, ensure sufficient randomness, and verify properly. The second approach handles all of this internally.
Use Timing-Safe Comparison
Prevent timing attacks during verification. Timing attacks measure how long comparison takes to determine how many characters match.
// Built into password_verify() - no extra work needed
password_verify($inputPassword, $hash);
// For other comparisons, use hash_equals()
if (hash_equals($expectedToken, $providedToken)) {
// Valid
}
The password_verify function already uses constant-time comparison, but for any other secret comparison, always use hash_equals instead of ===.
Implement Hash Upgrades
As hardware improves, increase hashing costs. Check and rehash on login when users authenticate. This is the only time you have access to the plaintext password:
// Laravel example
if (Hash::check($password, $user->password)) {
if (Hash::needsRehash($user->password)) {
$user->password = Hash::make($password);
$user->save();
}
// Login successful
}
This pattern lets you gradually migrate all active users to stronger hashing parameters without requiring password resets.
Pepper (Optional Additional Layer)
A pepper is a secret key added to passwords before hashing, stored separately from the database. This provides defense in depth for certain attack scenarios.
// Application-level pepper (stored in config, not database)
$pepper = config('app.password_pepper');
$hash = password_hash($password . $pepper, PASSWORD_ARGON2ID);
If the database is compromised but the application server isn't, the pepper provides extra protection. Keep in mind that rotating a pepper is difficult since all passwords would need to be rehashed.
Configuring Work Factors
bcrypt Cost
You should calibrate the cost factor based on your server's performance. The goal is to make hashing slow enough to deter attackers without noticeably impacting legitimate users.
// Target: 100-250ms on your server
$cost = 10; // Start here
$start = microtime(true);
password_hash('test', PASSWORD_BCRYPT, ['cost' => $cost]);
$end = microtime(true);
echo ($end - $start) . ' seconds';
// Increase cost until you hit target time
Run this benchmark periodically and adjust costs as your server hardware changes. Remember that attackers often have access to faster hardware than your production servers.
Argon2 Parameters
Argon2 offers more granular control over resource usage. You can tune memory, CPU time, and parallelization independently:
$options = [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4, // 4 iterations
'threads' => 3 // 3 parallel threads
];
$hash = password_hash($password, PASSWORD_ARGON2ID, $options);
The memory cost is particularly important since it makes attacks on specialized hardware more expensive. Start with the defaults and increase based on your server's available resources.
Additional Authentication Security
Password Requirements
Don't just enforce complexity rules. Modern guidance emphasizes length over complexity and checking against known breached passwords:
$rules = [
'password' => [
'required',
'min:12', // Minimum length
'not_in:password,123456,qwerty', // Block common passwords
new NotPwned(), // Check against breach databases
]
];
The NotPwned rule queries the Have I Been Pwned database to reject passwords that appear in known breaches. This is more effective than arbitrary complexity requirements.
Multi-Factor Authentication
Password hashing protects stored passwords. MFA protects against compromised passwords, even if an attacker knows the correct password. You can integrate MFA into your authentication flow with just a few lines of code.
// Using Laravel with a 2FA package
if ($user->hasTwoFactorEnabled()) {
return redirect()->route('two-factor.challenge');
}
This simple check redirects users to a second authentication step before granting access. Popular packages like Laravel Fortify provide built-in MFA support.
Account Lockout
Prevent brute force against individual accounts by implementing rate limiting. This protects even users with weak passwords from automated attacks:
// Laravel's built-in throttling
if (RateLimiter::tooManyAttempts($key, 5)) {
$seconds = RateLimiter::availableIn($key);
throw ValidationException::withMessages([
'email' => "Too many attempts. Try again in {$seconds} seconds."
]);
}
Be careful not to make lockouts too aggressive, as this can enable denial-of-service attacks against legitimate users.
Secure Password Reset
Password reset flows are a common attack vector. Follow these steps to implement them safely:
- Generate cryptographically secure token
- Hash the token before storing
- Set short expiration (1 hour max)
- Invalidate token after use
- Log password reset events
Never send the unhashed token via email if you can avoid it. Consider using a one-time link that leads to a form, rather than embedding the reset capability directly in the email link.
Conclusion
Password storage security requires using proven algorithms (Argon2id, bcrypt), letting libraries handle implementation details, planning for hash upgrades, and implementing complementary protections like MFA and rate limiting. The goal isn't just protecting your database;it's protecting your users even when things go wrong.