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
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:
phpfunction 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 plainhash()are fast hashes. Speed is the enemy: the same property that makes them good for checksums makes them useless for passwords.rand(),mt_rand()anduniqid()are predictable. So isstr_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_DEFAULTcan change algorithm between PHP versions. UsePASSWORD_ARGON2IDexplicitly, and letpassword_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.