DALT.PHP
Framework Deep Dive5. Authentication

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/igi

Let'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$zyxwvutsrqponmlkjihgfe0987654321ZYXWVUTSRQPONMLKJI

They'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:

  1. Extracts the salt from the stored hash
  2. Hashes the input password with that same salt
  3. Compares the result to the stored hash
  4. Returns true if they match, false otherwise

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:

  1. Adds random ingredients (salt)
  2. Blends it 1024 times (cost factor)
  3. 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 → 482c811da5d5b4bc6d497ffa98491e38

An attacker could:

  1. Pre-compute hashes for common passwords
  2. Look up your hash in their table
  3. Find the password instantly

With salt:

password123 + salt_abc → $2y$10$abc...xyz
password123 + salt_xyz → $2y$10$xyz...abc

Now:

  • 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() and password_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() and password_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.

On this page