ZetCode

PHP Password Hashing

last modified October 22, 2026

In this article, we show how to hash passwords in PHP with the built-in password_hash, password_verify and password_needs_rehash functions.

A password must never be stored in plain text. Fast general-purpose hashes such as md5 or sha1 are not suitable either, because they can be computed billions of times per second on a GPU.

A password hash has to be deliberately slow, salted, and its cost adjustable as hardware gets faster. PHP's password API provides all of this and picks a safe default.

Main Features of the password API

The password API consists of a few functions and algorithm constants.

Hashing and verifying a password

The password_hash function takes a password and an algorithm. The result is a self-contained string that holds the algorithm identifier, the cost, the salt and the hash.

The password_verify function reads those parameters from the string, so we only need to store one value.

hash_basic.php
<?php

$password = 's3cret-passw0rd';

$hash = password_hash($password, PASSWORD_DEFAULT);

echo $hash, "\n";
echo strlen($hash), "\n";

var_dump(password_verify($password, $hash));
var_dump(password_verify('wrong-password', $hash));

The bcrypt hash is always 60 characters long. The prefix $2y$12$ means bcrypt with cost 12.

We use PASSWORD_DEFAULT, which lets PHP move to a stronger algorithm in the future. For that reason the database column should be able to hold at least 255 characters.

Your hash will differ from the one below because the salt is random.

$ php hash_basic.php
$2y$12$msHI9uFTQa8RsisEN1B/PeD0AFXc5XeEnNVacuYeeFxND1Yxii4YG
60
bool(true)
bool(false)

Inspecting hashes

A stored hash carries its own settings, which we can read back. The example also shows what happens when the same password is hashed twice.

hash_info.php
<?php

$hash = password_hash('s3cret-passw0rd', PASSWORD_BCRYPT, ['cost' => 12]);

print_r(password_get_info($hash));

echo "\nSalt differs on each call:\n";
echo password_hash('abc', PASSWORD_DEFAULT), "\n";
echo password_hash('abc', PASSWORD_DEFAULT), "\n";

password_get_info reports the algorithm and options stored in a hash.

Hashing the same password twice produces different strings, because each call generates a new salt. This is why we never compare hashes with ==; we always use password_verify.

$ php hash_info.php
Array
(
    [algo] => 2y
    [algoName] => bcrypt
    [options] => Array
        (
            [cost] => 12
        )

)

Salt differs on each call:
$2y$12$K5KGjCqUvd3ENjCmf/GBOe62242tgF91u4FtYFNY4BQlXgbEvp17C
$2y$12$V75./r0gAldQHPoxihi6WersmvheKeqBNkzM2vLnFTLPOnYOMU4zu

Argon2

Besides bcrypt, PHP supports the Argon2 family of algorithms. In this example, we hash a password with Argon2id and custom options.

argon.php
<?php

$password = 's3cret-passw0rd';

$options = [
    'memory_cost' => 65536,  // KiB
    'time_cost'   => 4,      // iterations
    'threads'     => 1,
];

$hash = password_hash($password, PASSWORD_ARGON2ID, $options);

echo $hash, "\n";
var_dump(password_verify($password, $hash));
print_r(password_get_info($hash));

Argon2id is the winner of the Password Hashing Competition. It is memory-hard, which makes GPU attacks more expensive. It is available when PHP is built with Argon2 support, which most distributions enable.

The memory_cost is given in kibibytes, time_cost is the number of iterations, and threads the degree of parallelism.

Bcrypt remains a good choice; note that it only uses the first 72 bytes of a password.

$ php argon.php
$argon2id$v=19$m=65536,t=4,p=1$RVd2OFNxMHJKM1hDdnAwdA$YxOcLZWEX5SZFh+NjrD1d9sF2tzdjEt7F49BvTUtyQM
bool(true)
Array
(
    [algo] => argon2id
    [algoName] => argon2id
    [options] => Array
        (
            [memory_cost] => 65536
            [time_cost] => 4
            [threads] => 1
        )

)

Choosing the cost

The cost decides how much work each hash takes. The following script measures the time of bcrypt hashing for increasing costs.

cost.php
<?php

$target = 0.25; // seconds
$cost = 8;

do {
    $cost++;
    $start = microtime(true);
    password_hash('test', PASSWORD_BCRYPT, ['cost' => $cost]);
    $elapsed = microtime(true) - $start;
    printf("cost %2d: %.3f s\n", $cost, $elapsed);
} while ($elapsed < $target && $cost < 16);

echo "Appropriate cost: $cost\n";

The cost should be as high as your server can bear, since every login pays for it. A common target is a hash time of roughly 0.1 to 0.5 seconds on production hardware.

Each increment of the bcrypt cost doubles the time. The script raises the cost until a single hash takes 0.25 seconds. The timings are specific to the machine.

$ php cost.php
cost  9: 0.028 s
cost 10: 0.054 s
cost 11: 0.114 s
cost 12: 0.221 s
cost 13: 0.439 s
Appropriate cost: 13

Upgrading old hashes

Security requirements change over time. With password_needs_rehash we can move existing users to a higher cost or a stronger algorithm.

rehash.php
<?php

// a hash created long ago with a low cost
$stored = password_hash('s3cret-passw0rd', PASSWORD_BCRYPT, ['cost' => 8]);

$options = ['cost' => 12];
$password = 's3cret-passw0rd';

if (password_verify($password, $stored)) {
    echo "Login OK\n";

    if (password_needs_rehash($stored, PASSWORD_BCRYPT, $options)) {
        $stored = password_hash($password, PASSWORD_BCRYPT, $options);
        echo "Hash upgraded: ", substr($stored, 0, 7), "\n";
        // UPDATE users SET password = ? WHERE id = ?
    }
}

var_dump(password_needs_rehash($stored, PASSWORD_BCRYPT, $options));

The plain text password is available only at login, so that is the moment to upgrade.

After a successful password_verify we call password_needs_rehash with the current policy. When it returns true, we create a new hash and store it.

Users are migrated transparently when they log in. The same technique lets us move from an older algorithm to Argon2id.

$ php rehash.php
Login OK
Hash upgraded: $2y$12$
bool(false)

Registration and login with PDO

In practice, hashes are stored in a database. The example implements registration and login with an in-memory SQLite database and prepared statements.

register.php
<?php

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT UNIQUE, password TEXT)');

function register(PDO $pdo, string $name, string $password): void
{
    if (strlen($password) < 8) {
        throw new InvalidArgumentException('Password must have at least 8 characters.');
    }

    $stmt = $pdo->prepare('INSERT INTO users (name, password) VALUES (?, ?)');
    $stmt->execute([$name, password_hash($password, PASSWORD_DEFAULT)]);
}

function login(PDO $pdo, string $name, string $password): bool
{
    $stmt = $pdo->prepare('SELECT password FROM users WHERE name = ?');
    $stmt->execute([$name]);
    $hash = $stmt->fetchColumn();

    return $hash !== false && password_verify($password, $hash);
}

register($pdo, 'alice', 'correct horse battery');

var_dump(login($pdo, 'alice', 'correct horse battery'));
var_dump(login($pdo, 'alice', 'Tr0ub4dor'));
var_dump(login($pdo, 'bob', 'anything'));

try {
    register($pdo, 'carol', 'short');
} catch (InvalidArgumentException $e) {
    echo $e->getMessage(), "\n";
}

The register function enforces a minimum length and stores only the hash. The login function fetches the hash by user name and verifies it.

The login function gives the same answer for an unknown user and for a wrong password, so an attacker cannot learn which user names exist.

Strictly speaking, a failed lookup is also faster than a failed verification. To hide that too, verify against a dummy hash when the user is not found.

$ php register.php
bool(true)
bool(false)
bool(false)
Password must have at least 8 characters.

Good practice

The following rules summarize how to handle passwords safely.

Summary

In this article, we have used password_hash, password_verify and password_needs_rehash to store passwords safely, compared bcrypt with Argon2id, and tuned the cost of the hash.

Author

My name is Jan Bodnar and I am a passionate programmer with many years of programming experience. I have been writing programming articles since 2007. So far, I have written over 1400 articles and 8 e-books. I have over eight years of experience in teaching programming.

List all PHP tutorials.