← All articles

Generating and hashing passwords in PHP

PHP ships the right primitives in the standard library: a CSPRNG and both Argon2id and bcrypt behind one function each. The work is mostly about not stepping around them.

1. Generate a password

random_int() is the only random function in PHP that is safe here. It is backed by the operating system CSPRNG, it is already free of modulo bias, and it throws an exception instead of quietly returning weak values.

php
<?php
declare(strict_types=1);

const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
    . 'abcdefghijklmnopqrstuvwxyz'
    . '0123456789'
    . '!@#$%^&*()-_=+[]{};:,.?/~';

function generatePassword(int $length = 24, string $alphabet = ALPHABET): string
{
    if ($length < 1 || $alphabet === '') {
        throw new InvalidArgumentException('Length and alphabet must be non-empty.');
    }
    $max = strlen($alphabet) - 1;
    $password = '';
    for ($i = 0; $i < $length; $i++) {
        // random_int($min, $max) is inclusive and unbiased.
        $password .= $alphabet[random_int(0, $max)];
    }
    return $password;
}

If you also want a guarantee that at least one character comes from every selected set, build one character per set first and shuffle afterwards — using the same CSPRNG:

php
function generateStructuredPassword(int $length = 24): string
{
    $sets = [
        'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
        'abcdefghijklmnopqrstuvwxyz',
        '0123456789',
        '!@#$%^&*()-_=+[]{};:,.?/~',
    ];

    $chars = [];
    foreach ($sets as $set) {
        $chars[] = $set[random_int(0, strlen($set) - 1)];
    }

    $pool = implode('', $sets);
    while (count($chars) < $length) {
        $chars[] = $pool[random_int(0, strlen($pool) - 1)];
    }

    // Fisher-Yates: without this the guaranteed characters are always first.
    for ($i = count($chars) - 1; $i > 0; $i--) {
        $j = random_int(0, $i);
        [$chars[$i], $chars[$j]] = [$chars[$j], $chars[$i]];
    }

    return implode('', $chars);
}

2. Hash with Argon2id

Argon2id is available from PHP 7.4 onwards. Memory is in KiB, so 65536 means 64 MiB per hash attempt:

php
// Guard against a PHP build without Argon2 support.
if (!defined('PASSWORD_ARGON2ID')) {
    throw new RuntimeException('This PHP build has no Argon2id support.');
}

$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536, // KiB — 64 MiB
    'time_cost'   => 3,     // passes over that memory
    'threads'     => 4,     // parallel lanes
]);

// $hash is a self-describing PHC string:
// $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>

3. Hash with bcrypt

php
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// $2y$12$...

bcrypt only reads the first 72 bytes of the input. A 128-character generated password and a different password that shares its first 72 bytes will verify against the same hash — worth knowing before you generate very long passwords for a bcrypt-only system.

4. Verify, and upgrade parameters while you are there

php
$stored = fetch_hash_from_database($userId); // the full PHC string

if ($stored === null || !password_verify($candidate, $stored)) {
    // Never say whether the account exists. Same message, same timing path.
    http_response_code(401);
    exit('Invalid credentials.');
}

// The password is correct. Did the parameters move since it was stored?
$options = [
    'memory_cost' => 131072, // 128 MiB now
    'time_cost'   => 4,
    'threads'     => 4,
];

if (password_needs_rehash($stored, PASSWORD_ARGON2ID, $options)) {
    $upgraded = password_hash($candidate, PASSWORD_ARGON2ID, $options);
    save_hash_for_user($userId, $upgraded);
}

Pitfalls in PHP

  • md5(), sha1() and plain hash() are fast hashes. Speed is the enemy: the same property that makes them good for checksums makes them useless for passwords.
  • rand(), mt_rand() and uniqid() are predictable. So is str_shuffle(), which is seeded by the same generator.
  • Storing the salt separately is unnecessary: the PHC string already contains the salt and the parameters, so store the whole string in one column.
  • PASSWORD_DEFAULT can change algorithm between PHP versions. Use PASSWORD_ARGON2ID explicitly, and let password_needs_rehash() handle migrations.
  • Comparing hashes with == or === is unnecessary here — password_verify() already compares in constant time — but doing it yourself anywhere else leaks timing information.

Trying it without writing a script

If you just need to check that your configuration produces the hash you expect, the Hash and Verify tabs on this site run the same algorithms (through Go's x/crypto package) and show you the exact PHC string, including the parameters, so you can compare it with what PHP produced.

Check a PHP hash against a password — paste both, get match or no match, without writing a test script.