0

Password hashing integration — Argon2 via sodium

Intermediate5 min read·fw-09-005
security

Concept

Password hashing is the secure storage of passwords. Never store plaintext passwords. A hash is a one-way function — you can verify a password against a hash, but you can't reverse the hash to get the password.

Modern password hashing algorithms:

  • bcrypt: The standard for years. Work factor (cost) parameter — higher = slower = more secure. PHP's password_hash($pass, PASSWORD_BCRYPT, ['cost' => 12]).
  • Argon2id: Recommended by OWASP. Memory-hard (resistant to GPU attacks). password_hash($pass, PASSWORD_ARGON2ID). PHP 7.4+, requires the sodium extension.
  • SHA-1, MD5: NEVER use for passwords — too fast, easily brute-forced. OK for checksums, not passwords.

PHP password API:

  • password_hash(string $password, int $algo, array $options = []): Hash a password. Returns the hash (includes the algorithm and salt embedded in the string).
  • password_verify(string $password, string $hash): Verify a password against a hash. Returns bool.
  • password_needs_rehash(string $hash, int $algo, array $options = []): Check if a hash was created with different settings (e.g., after upgrading cost factor).

Salting: PHP's password_hash() generates a random salt automatically and embeds it in the hash string. Never pass your own salt — the built-in random salt is secure.

Sodium extension (ext-sodium): Provides sodium_crypto_pwhash_str() and sodium_crypto_pwhash_str_verify() for Argon2id. Also provides authenticated encryption, secret-key signing, and more.

Timing attacks: password_verify() uses constant-time comparison — it always takes the same time regardless of where the comparison fails. Never use === for hash comparison.

Code Example

php
<?php
namespace Framework\Auth;

interface HasherInterface
{
    public function make(string $value, array $options = []): string;
    public function check(string $value, string $hash): bool;
    public function needsRehash(string $hash, array $options = []): bool;
}

class BcryptHasher implements HasherInterface
{
    public function __construct(private readonly int $cost = 12) {}

    public function make(string $value, array $options = []): string
    {
        $cost = $options['cost'] ?? $this->cost;
        $hash = password_hash($value, PASSWORD_BCRYPT, ['cost' => $cost]);
        if ($hash === false) {
            throw new \RuntimeException('Bcrypt hashing not supported.');
        }
        return $hash;
    }

    public function check(string $value, string $hash): bool
    {
        return password_verify($value, $hash); // constant-time comparison
    }

    public function needsRehash(string $hash, array $options = []): bool
    {
        $cost = $options['cost'] ?? $this->cost;
        return password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => $cost]);
    }
}

class Argon2IdHasher implements HasherInterface
{
    public function make(string $value, array $options = []): string
    {
        $hash = password_hash($value, PASSWORD_ARGON2ID, $options);
        if ($hash === false) {
            throw new \RuntimeException('Argon2id hashing not supported. Install ext-sodium.');
        }
        return $hash;
    }

    public function check(string $value, string $hash): bool
    {
        return password_verify($value, $hash);
    }

    public function needsRehash(string $hash, array $options = []): bool
    {
        return password_needs_rehash($hash, PASSWORD_ARGON2ID, $options);
    }
}

// Usage in UserProvider
class EloquentUserProvider
{
    public function __construct(
        private readonly string $model,
        private readonly HasherInterface $hasher,
    ) {}

    public function validateCredentials(\Framework\Auth\Authenticatable $user, array $credentials): bool
    {
        $plain = $credentials['password'];
        if (!$this->hasher->check($plain, $user->getAuthPassword())) {
            return false;
        }
        // Upgrade hash if needed (e.g., after cost factor change)
        if ($this->hasher->needsRehash($user->getAuthPassword())) {
            $user->setAttribute('password', $this->hasher->make($plain));
            $user->save();
        }
        return true;
    }
}

// In a controller
$hash = $hasher->make('user_password123'); // store this
$hasher->check('user_password123', $hash); // true
$hasher->check('wrong_password',   $hash); // false