Password Security
How password hashing works and why it matters
Why We Never Store Plain Text Passwords
Imagine if your database looked like this:
| id | email | password |
|----|-----------------|-------------|
| 1 | alice@test.com | alice123 |
| 2 | bob@test.com | password |
| 3 | carol@test.com | qwerty |If an attacker gets access to your database (SQL injection, backup leak, insider threat), they now have everyone's passwords.
Worse: people reuse passwords. So the attacker can now:
- Log into their email
- Access their bank account
- Steal their social media
This is why we NEVER store passwords in plain text.
Password Hashing: The Solution
Instead of storing the actual password, we store a "hash" - a scrambled version that can't be reversed.
| id | email | password |
|----|-----------------|--------------------------------------------------------------|
| 1 | alice@test.com | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi |
| 2 | bob@test.com | $2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm |
| 3 | carol@test.com | $2y$10$eUDJZvzk/z.6JqRKQoxp4eLEZOYqCZ8JLFbXn3XQcOvpZKGQsVK9C |Now if the database leaks, the attacker can't use these hashes to log in anywhere.
How DALT.PHP Handles Passwords
When Registering (Creating a User)
// .dalt/stubs/auth/Http/controllers/registration/store.php
$db = App::resolve(Database::class);
$db->query('INSERT INTO users(email, password) VALUES(:email, :password)', [
'email' => $email,
'password' => password_hash($password, PASSWORD_BCRYPT)
]);Key function: password_hash()
password_hash($password, PASSWORD_BCRYPT)This takes the plain text password and returns a hash.
When Logging In (Checking a Password)
// framework/Core/Authenticator.php
$user = App::resolve(Database::class)->query('select * from users where email = :email', [
'email' => $email
])->find();
if ($user) {
if (password_verify($password, $user['password'])) {
$this->login(['email' => $email]);
return true;
}
}Key function: password_verify()
password_verify($password, $user['password'])This checks if the plain text password matches the stored hash.
How password_hash() Works
The Algorithm: Bcrypt
password_hash($password, PASSWORD_BCRYPT)PASSWORD_BCRYPT uses the bcrypt algorithm, which is:
- Slow by design (makes brute force attacks harder)
- Includes a random "salt" (explained below)
- Industry standard for password hashing
What a Hash Looks Like
$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igiLet's break this down:
$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
│ │ │ │
│ │ │ └─ Hash (31 chars)
│ │ └─ Salt (22 chars)
│ └─ Cost factor (10 = 2^10 = 1024 iterations)
└─ Algorithm identifier ($2y = bcrypt)Algorithm identifier: $2y$
- Tells PHP this is a bcrypt hash
- Different algorithms have different identifiers
Cost factor: 10
- How many times to run the hashing algorithm
- Higher = slower = more secure (but slower for users too)
- Default is 10, which is good for most apps
Salt: 92IXUNpkjO0rOQ5byMi.Ye
- Random data added to the password before hashing
- Makes rainbow table attacks impossible (explained below)
- Generated automatically by
password_hash()
Hash: 4oKoEa3Ro9llC/.og/at2.uheWG/igi
- The actual scrambled password
Why the Same Password Produces Different Hashes
Try this:
echo password_hash('password123', PASSWORD_BCRYPT) . "\n";
echo password_hash('password123', PASSWORD_BCRYPT) . "\n";Output:
$2y$10$abcdefghijklmnopqrstuv1234567890ABCDEFGHIJKLMNOPQR
$2y$10$zyxwvutsrqponmlkjihgfe0987654321ZYXWVUTSRQPONMLKJIThey're different! Why?
Because each call generates a new random salt. This is a feature, not a bug.
How password_verify() Works
password_verify($password, $hash)This function:
- Extracts the salt from the stored hash
- Hashes the input password with that same salt
- Compares the result to the stored hash
- Returns
trueif they match,falseotherwise
Important: You can't compare hashes directly:
// ❌ THIS DOESN'T WORK
if (password_hash($password, PASSWORD_BCRYPT) === $user['password']) {
// This will always be false!
}Because each call to password_hash() produces a different hash (different salt).
Always use password_verify():
// ✅ THIS WORKS
if (password_verify($password, $user['password'])) {
// Correct!
}ELI5: How Password Hashing Works
Imagine you have a secret recipe:
Plain text password: "chocolate chip cookies"
Hashing: You put the recipe through a magic blender that:
- Adds random ingredients (salt)
- Blends it 1024 times (cost factor)
- Produces a smoothie that looks nothing like the original
The smoothie: $2y$10$abc...xyz
Now:
- You can't "unblend" the smoothie to get the recipe back
- But if someone gives you "chocolate chip cookies", you can blend it the same way and see if it produces the same smoothie
- Even if two people have the same recipe, their smoothies look different (because of random ingredients)
Why Salts Matter: Rainbow Tables
Without salts, attackers could use "rainbow tables" - pre-computed tables of password hashes.
Without salt:
password123 → 482c811da5d5b4bc6d497ffa98491e38An attacker could:
- Pre-compute hashes for common passwords
- Look up your hash in their table
- Find the password instantly
With salt:
password123 + salt_abc → $2y$10$abc...xyz
password123 + salt_xyz → $2y$10$xyz...abcNow:
- Each user has a different salt
- The attacker would need to compute a rainbow table for each salt
- This makes rainbow tables impractical
Why Bcrypt is Slow (On Purpose)
Bcrypt is designed to be slow. This seems bad, but it's actually good.
For legitimate users:
- Hashing takes ~0.1 seconds
- Barely noticeable when logging in
For attackers:
- Trying 1 million passwords takes ~27 hours
- Trying 1 billion passwords takes ~3 years
Compare this to fast hashes like MD5:
- Trying 1 billion passwords takes ~1 second
This is why we use bcrypt for passwords.
Password Requirements in DALT.PHP
Currently, DALT.PHP has minimal password validation:
// .dalt/stubs/auth/Http/controllers/registration/store.php
if (!Validator::string($password, 7, 255)) {
$errors['password'] = 'Please provide a password of at least seven characters.';
}This only checks:
- Minimum 7 characters
- Maximum 255 characters
No requirements for:
- Uppercase letters
- Numbers
- Special characters
This is actually reasonable. Research shows:
- Length matters more than complexity
- "correct horse battery staple" is stronger than "P@ssw0rd!"
- Complexity requirements often lead to predictable patterns
Common Password Attacks
Brute Force
Trying every possible password:
a,b,c, ...,aa,ab, ...
Bcrypt's slowness makes this impractical.
Dictionary Attack
Trying common passwords:
password,123456,qwerty, ...
This is why you should require minimum length and check against common password lists.
Credential Stuffing
Using leaked passwords from other sites:
- Attacker gets passwords from Site A
- Tries them on Site B
This is why you should:
- Never reuse passwords
- Use a password manager
- Enable two-factor authentication
Rainbow Tables
Pre-computed hash tables (explained above).
Salts make this attack impossible.
Upgrading Password Security
Checking Password Strength
You could add a check against common passwords:
$commonPasswords = ['password', '123456', 'qwerty', 'abc123', ...];
if (in_array(strtolower($password), $commonPasswords)) {
$errors['password'] = 'This password is too common. Please choose a stronger password.';
}Or use a library like zxcvbn to estimate password strength.
Increasing the Cost Factor
password_hash($password, PASSWORD_BCRYPT, ['cost' => 12])This makes hashing slower (more secure), but also slower for your users.
Test on your server to find a good balance:
- Target: ~0.1-0.5 seconds per hash
- Too fast: not secure enough
- Too slow: bad user experience
Using Argon2
PHP 7.2+ supports Argon2, a newer algorithm:
password_hash($password, PASSWORD_ARGON2ID)Argon2 is considered more secure than bcrypt, but bcrypt is still fine for most apps.
Rehashing Old Passwords
If you upgrade your algorithm or cost factor, old hashes won't automatically update.
You can rehash on login:
if (password_verify($password, $user['password'])) {
// Check if hash needs upgrade
if (password_needs_rehash($user['password'], PASSWORD_BCRYPT, ['cost' => 12])) {
// Rehash with new settings
$newHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// Update database
$db->query('UPDATE users SET password = :password WHERE id = :id', [
'password' => $newHash,
'id' => $user['id']
]);
}
$this->login($user);
return true;
}What's Good in DALT.PHP
- Uses
password_hash()andpassword_verify()(correct approach) - Uses bcrypt (industry standard)
- Salts are automatic (no manual salt management)
- Simple to understand and implement
Production Notes (Optional)
For learning, DALT focuses on correct hashing and verification. In a production app you typically also add:
- Password confirmation
- Common-password checks
- Password reset (“forgot password” flow)
- Password change (verify old password, set new password)
Security Best Practices Summary
DO:
- ✅ Use
password_hash()andpassword_verify() - ✅ Use bcrypt or Argon2
- ✅ Require minimum password length (8+ characters)
- ✅ Check against common password lists
- ✅ Use HTTPS (so passwords aren't sent in plain text)
- ✅ Rate limit login attempts
DON'T:
- ❌ Store passwords in plain text
- ❌ Use MD5 or SHA1 for passwords
- ❌ Implement your own hashing algorithm
- ❌ Compare hashes with
=== - ❌ Email passwords to users
- ❌ Show passwords in logs or error messages
Testing Password Hashing
Here's how to verify it works:
// 1. Hash a password
$hash = password_hash('mypassword', PASSWORD_BCRYPT);
echo "Hash: $hash\n";
// 2. Verify correct password
if (password_verify('mypassword', $hash)) {
echo "✅ Correct password\n";
}
// 3. Verify wrong password
if (!password_verify('wrongpassword', $hash)) {
echo "✅ Wrong password rejected\n";
}
// 4. Verify hashes are different each time
$hash1 = password_hash('mypassword', PASSWORD_BCRYPT);
$hash2 = password_hash('mypassword', PASSWORD_BCRYPT);
if ($hash1 !== $hash2) {
echo "✅ Hashes are unique (different salts)\n";
}Common Mistakes to Avoid
Using MD5 or SHA1
// ❌ INSECURE
$hash = md5($password);
$hash = sha1($password);These are:
- Too fast (easy to brute force)
- No salt (vulnerable to rainbow tables)
- Designed for data integrity, not passwords
Comparing Hashes Directly
// ❌ DOESN'T WORK
if (password_hash($password, PASSWORD_BCRYPT) === $user['password']) {
// Always false due to different salts
}Always use password_verify().
Storing Passwords in Logs
// ❌ SECURITY RISK
error_log("User login attempt: email=$email, password=$password");Never log passwords, even for debugging.
Limiting Password Length Too Much
// ❌ TOO RESTRICTIVE
if (strlen($password) > 20) {
$errors['password'] = 'Password too long';
}There's no security reason to limit password length (bcrypt handles long passwords fine). Let users use password managers that generate long passwords.
Not Using HTTPS
If your site uses HTTP instead of HTTPS:
- Passwords are sent in plain text over the network
- Anyone on the same WiFi can see them
- All your hashing is useless
Always use HTTPS in production.