0

Password hashing — password_hash, bcrypt, Argon2, cost factor

Intermediate5 min read·php-16-004
securityinterview

Concept

Password storage is a critical security concern. Passwords must never be stored in plaintext, and should never use fast hashing algorithms (MD5, SHA1, SHA256) — these can be brute-forced with GPU clusters at billions of hashes per second.

password_hash(string $password, int $algo, array $options = []): PHP's built-in secure password hashing. Uses password-specific, slow algorithms with automatic salting. Returns an opaque string that includes the algorithm, cost factors, salt, and hash.

Algorithms:

  • PASSWORD_BCRYPT: BCrypt. Cost factor 4-31 (default 10 — tune so hashing takes ~100ms on your hardware). Battle-tested since 1999. Max password length: 72 bytes (silently truncates!).
  • PASSWORD_ARGON2I (PHP 7.2+): Argon2i — resistant to GPU cracking and side-channel attacks. Memory-hard.
  • PASSWORD_ARGON2ID (PHP 7.3+): Argon2id — combines Argon2i and Argon2d. Recommended by OWASP over Argon2i. Use this for new projects.
  • PASSWORD_DEFAULT: Currently PASSWORD_BCRYPT. Will be updated in future PHP versions. Safe to use since hashes include algorithm info.

password_verify(string $password, string $hash): bool: Compares a password against a hash. Timing-safe — uses constant-time comparison.

password_needs_rehash(string $hash, int $algo, array $options = []): bool: Returns true if the hash was made with a different algorithm or cost factor. Call after successful login and rehash if true.

Hash length: BCrypt produces 60-character hashes. Argon2id produces longer hashes (96+ chars). Store hash in VARCHAR(255) or TEXT.

Code Example

php
<?php
declare(strict_types=1);

// WRONG — never store passwords like this
$hash_md5  = md5($password);     // cracks in seconds on GPU
$hash_sha1 = sha1($password);    // also fast — also crackable
$hash_bad  = hash('sha256', $password); // still fast — wrong tool

// CORRECT — bcrypt (default, solid choice)
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// Store $hash in database — looks like: $2y$12$SALT...HASHDATA

// CORRECT — Argon2id (recommended for new systems)
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536, // 64MB
    'time_cost'   => 4,
    'threads'     => 2,
]);

// Verify on login
function verifyLogin(string $inputPassword, string $storedHash): bool
{
    return password_verify($inputPassword, $storedHash);
    // timing-safe — same time whether hash matches or not
}

// Full login flow with rehashing
function login(string $email, string $inputPassword): ?User
{
    $user = User::where('email', $email)->first();
    if (!$user) {
        // Compute a hash anyway to prevent timing attacks revealing valid emails
        password_hash($inputPassword, PASSWORD_ARGON2ID); // throw away result
        return null;
    }

    if (!password_verify($inputPassword, $user->password)) {
        return null;
    }

    // Upgrade hash if algorithm/cost factor changed
    if (password_needs_rehash($user->password, PASSWORD_ARGON2ID, ['memory_cost' => 65536])) {
        $user->update(['password' => password_hash($inputPassword, PASSWORD_ARGON2ID, ['memory_cost' => 65536])]);
    }

    return $user;
}

// Tune bcrypt cost (target: ~100ms on production hardware)
$start = microtime(true);
password_hash('test', PASSWORD_BCRYPT, ['cost' => 12]);
$elapsed = microtime(true) - $start;
echo "Cost 12: {$elapsed}s\n"; // should be ~0.1s

// Laravel uses Bcrypt by default (config/hashing.php)
// Change to Argon2id in config/hashing.php:
// 'driver' => 'argon2id',
// Hash::make($password)