Storing Passwords

Store passwords with password_hash and check them with password_verify; rehash at login when the cost or algorithm moves on.

Syntax$hash = password_hash($password, PASSWORD_DEFAULT); password_verify($password, $hash);

You never store a password. You store a hash: a one-way transformation that you can recompute from a password but cannot reverse. At login you hash what was typed and compare the results, so a stolen database does not hand over anyone's password.

A plain md5() or sha1() is not enough, and the reason is speed. Those functions are built to be fast, so a cheap machine tries billions of guesses a second against a leaked table. A password hash is built to be slow and salted on purpose.

Two functions do the work

password_hash() takes the password and an algorithm and returns a string of about 60 characters. That string already contains the algorithm, the cost and a random salt, so there is no separate salt column and two identical passwords produce different hashes. password_verify() takes the password and the stored string, reads the settings out of it, and returns a boolean. Store it in a VARCHAR(255) column, not VARCHAR(60), so a future algorithm still fits.

Upgrading as hardware gets faster

PASSWORD_DEFAULT is bcrypt today, and in PHP 8.4 its default cost rose from 10 to 12. password_needs_rehash() tells you whether a stored hash was made with older settings. Call it at login, where you have the plain password in hand, and write a fresh hash when it returns true.

A pepper, a secret added to every password, only helps while the key stays secret and unchanged. Rotating it means rehashing everyone, which is why most projects skip it.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Saving a new password when someone registers or changes theirs.
  • Checking a submitted password at login without storing anything reversible.
  • Migrating a table of bcrypt hashes to a higher cost after upgrading to PHP 8.4 or later.
  • Choosing a password column type that will still hold next decade's hashes.

More examples

Login with a transparent rehash

This is the shape of every login handler. The lookup uses a prepared statement, the comparison uses password_verify rather than ==, and the rehash happens after a successful verify. Upgrading an account's hash costs one UPDATE and the user notices nothing, which is how a whole table migrates to a stronger cost over a few months.

Example · php
<?php

declare(strict_types=1);

function attemptLogin(PDO $pdo, string $email, string $password): ?int
{
    $stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ? LIMIT 1');
    $stmt->execute([$email]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($user === false) {
        return null;
    }

    if (!password_verify($password, $user['password_hash'])) {
        return null;
    }

    // Only possible here, because this is the one place the plain
    // password exists.
    if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
        $fresh = password_hash($password, PASSWORD_DEFAULT);
        $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
            ->execute([$fresh, $user['id']]);
    }

    return (int) $user['id'];
}

Not telling an attacker which emails exist

Returning early when the email is unknown makes that response measurably faster than a wrong-password response, which turns your login form into a tool for testing whether an address is registered. Running a throwaway verify evens out the timing, and a single error message for both cases closes the obvious version of the same leak.

Example · php
<?php

declare(strict_types=1);

// A bcrypt hash of a value nobody uses, kept as a constant.
const DUMMY_HASH = '$2y$12$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';

$user = findUserByEmail($pdo, $email);

if ($user === null) {
    // Spend the same time as a real check, so a missing account and a
    // wrong password take the same number of milliseconds.
    password_verify($password, DUMMY_HASH);
    $ok = false;
} else {
    $ok = password_verify($password, $user['password_hash']);
}

// One message for both cases.
if (!$ok) {
    $error = 'Those details did not match.';
}

Choosing argon2id explicitly

argon2id is memory-hard, which makes it harder to attack with the kind of hardware built for bcrypt. Reach for it when your build has it and you can afford the memory, and measure before raising the cost: 64 MiB per hash is per concurrent login, so a login storm has to fit in the server's RAM. Check password_algos() first, because the argon2 algorithms depend on how PHP was compiled.

Example · php
<?php

declare(strict_types=1);

$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536,  // KiB, so 64 MiB per hash
    'time_cost' => 4,        // passes over memory
    'threads' => 1,
]);

// password_algos() lists what this build supports.
print_r(password_algos());
// Array ( [0] => 2y [1] => argon2i [2] => argon2id )

Discussion

  • Be the first to comment on this lesson.