Password Hashing & Storage

+15 Mana ✨

Introduction

Never store passwords in plain text. Use proper cryptographic hashing.

Key Concepts

  • password_hash(): PHP's built-in function that generates a secure bcrypt or Argon2 hash with automatic salting.
  • password_verify(): Safely compares a plain-text password against a stored hash without revealing timing information.
  • Adaptive Hashing: Algorithms like bcrypt and Argon2 can increase their cost factor as hardware improves.
  • password_needs_rehash(): Detects when a stored hash uses outdated settings and should be re-hashed on next login.

Real World Context

The 2012 LinkedIn breach exposed 117 million passwords stored as unsalted SHA-1 hashes, which were cracked within days. Using password_hash() with bcrypt or Argon2id would have made those hashes computationally infeasible to crack, even with modern GPU hardware.

Deep Dive

Intro

Never store passwords in plain text. Use proper cryptographic hashing.

The password_hash() function

php
<?php
// Creating a hash
$password = 'user_password_here';
$hash = password_hash($password, PASSWORD_DEFAULT);
// Output: $2y$10$... (60+ characters)

// PASSWORD_DEFAULT uses bcrypt (currently)
// Automatically generates a secure salt
// Future-proof: algorithm may change

Verifying passwords

php
<?php
$submitted = $_POST['password'];
$storedHash = $user['password_hash'];  // From database

if (password_verify($submitted, $storedHash)) {
    // Password is correct
    login($user);
} else {
    // Password is wrong
    // Don't reveal which field was wrong!
    throw new AuthenticationException('Invalid credentials');
}

Algorithm options

php
<?php
// Bcrypt (default, recommended for most cases)
$hash = password_hash($password, PASSWORD_BCRYPT, [
    'cost' => 12  // Higher = slower = more secure
]);

// Argon2id (PHP 7.3+, best for new projects)
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536,  // 64 MB
    'time_cost' => 4,        // 4 iterations
    'threads' => 3           // 3 threads
]);

Rehashing when needed

php
<?php
function login(string $password, string $hash): bool {
    if (!password_verify($password, $hash)) {
        return false;
    }
    
    // Check if hash needs upgrade
    if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
        $newHash = password_hash($password, PASSWORD_DEFAULT);
        updateUserPasswordHash($user->id, $newHash);
    }
    
    return true;
}

Timing attacks

php
<?php
// WRONG: Early return reveals information
if (strlen($password) < 8) {
    return false;  // Fast response = password too short
}

// WRONG: String comparison timing
if ($hash === $expected) {  // Timing varies by position
    return true;
}

// RIGHT: Constant-time comparison
if (hash_equals($expected, $hash)) {
    return true;
}

Common Pitfalls

php
<?php
// WRONG: Plain text storage
$user->password = $password;

// WRONG: Simple hashing (crackable)
$hash = md5($password);
$hash = sha1($password);
$hash = hash('sha256', $password);

// WRONG: Unsalted hash (rainbow tables)
$hash = hash('sha256', $password);

// WRONG: Static salt (if leaked, all passwords vulnerable)
$hash = hash('sha256', 'static_salt' . $password);

// RIGHT: password_hash() with unique salt per password
$hash = password_hash($password, PASSWORD_DEFAULT);

Best Practices

  1. Use Argon2id for new projects — PASSWORD_ARGON2ID (PHP 7.3+) is resistant to both GPU and side-channel attacks. Use PASSWORD_DEFAULT (bcrypt) as a reliable fallback.
  2. Implement password_needs_rehash() in login flow — Automatically upgrade password hashes when algorithm settings change, ensuring all active users get the latest protection.

Summary

  • Always use password_hash() with PASSWORD_DEFAULT or PASSWORD_ARGON2ID — never MD5, SHA1, or plain SHA-256.
  • Use password_verify() for authentication and password_needs_rehash() to upgrade hashes over time.
  • Never store plain text passwords or use homegrown hashing schemes.

Code Examples

php
<?php
declare(strict_types=1);

class PasswordService {
    private const ALGORITHM = PASSWORD_ARGON2ID;
    private const OPTIONS = [
        'memory_cost' => 65536,
        'time_cost' => 4,
        'threads' => 3,
    ];
    
    public function hash(string $password): string {
        $this->validateStrength($password);
        return password_hash($password, self::ALGORITHM, self::OPTIONS);
    }
    
    public function verify(string $password, string $hash): bool {
        return password_verify($password, $hash);
    }
    
    public function needsRehash(string $hash): bool {
        return password_needs_rehash($hash, self::ALGORITHM, self::OPTIONS);
    }
    
    public function validateStrength(string $password): void {
        $errors = [];
        
        if (strlen($password) < 8) {
            $errors[] = 'Password must be at least 8 characters';
        }
        
        if (!preg_match('/[A-Z]/', $password)) {
            $errors[] = 'Password must contain an uppercase letter';
        }
        
        if (!preg_match('/[a-z]/', $password)) {
            $errors[] = 'Password must contain a lowercase letter';
        }
        
        if (!preg_match('/[0-9]/', $password)) {
            $errors[] = 'Password must contain a number';
        }
        
        if ($errors) {
            throw new WeakPasswordException(implode('. ', $errors));
        }
    }
}

// Registration
$passwordService = new PasswordService();
$hash = $passwordService->hash($_POST['password']);
$stmt->execute(['password_hash' => $hash]);

// Login
if ($passwordService->verify($_POST['password'], $user['password_hash'])) {
    if ($passwordService->needsRehash($user['password_hash'])) {
        $newHash = $passwordService->hash($_POST['password']);
        // Update in database
    }
    // Login successful
}
?>
✓ Completed