Blog / Coding tips

PHP password_hash and password_verify Example (Login)

In PHP, you store a password by saving the result of password_hash($password, PASSWORD_DEFAULT) in your database, and you check a login with password_verify($typedPassword, $storedHash), which returns true or false. You never decrypt the hash and you never compare hashes yourself: password_verify() reads the algorithm, cost and salt from the stored hash and does the comparison safely. Below is a complete register and login example with PDO, the real output from running it on PHP 8.4, and the details that matter in practice: column size, upgrading old hashes, and bcrypt's 72-byte limit.

I wrote this as the next step after my posts on PHP PDO prepared statements and PHP form validation. Once a form saves data safely, the obvious next thing people build is a login, and that's where I see md5() still being copied from old tutorials. All the code below was run with PHP 8.4 on the command line, and the outputs are pasted from the terminal.

The basic example

<?php
// basic.php: hash a password, then check it
$password = 'correct horse battery staple';

$hash = password_hash($password, PASSWORD_DEFAULT);
echo $hash, PHP_EOL;
echo strlen($hash), ' characters', PHP_EOL;

var_dump(password_verify('correct horse battery staple', $hash));
var_dump(password_verify('Correct horse battery staple', $hash));

// Same password, new random salt, different hash every time
echo password_hash($password, PASSWORD_DEFAULT), PHP_EOL;
print_r(password_get_info($hash));

Output (your hashes will be different):

$2y$12$0CaptF8u.YOXJoejUoaYqO2pEOjK6TiOO8gPlUKKKms/bl5/ZsQ1C
60 characters
bool(true)
bool(false)
$2y$12$jaeKPXJEuIOO6umE8I19WOGptbxqdxK6ZKJbFMyzSHDQwjk3CEM4C
Array
(
    [algo] => 2y
    [algoName] => bcrypt
    [options] => Array
        (
            [cost] => 12
        )

)

Here's how to read a hash like $2y$12$0CaptF8u.YOXJoejUoaYqO2...:

  • $2y$ is the algorithm, bcrypt.
  • 12$ is the cost. Each step up doubles the work. PHP 8.4 raised the default from 10 to 12.
  • The next 22 characters are a random salt that PHP made for you.
  • The rest is the hash itself.

Because everything needed to check the password is stored inside the string, you only need one database column. And because the salt is random, hashing the same password twice gives two different strings, as the output shows. That's why you can't check a login by hashing the typed password and comparing it with ===. Use password_verify(), which also compares in constant time so the timing doesn't leak anything.

Notice that a single capital letter makes password_verify() return false. Passwords are case-sensitive, and you shouldn't change that by lower-casing them before hashing.

A register and login example with PDO

Here's a complete flow with a users table. I used SQLite in memory so the script runs anywhere, but the queries are the same for MySQL:

<?php
// auth.php: register and log in with password_hash() and PDO
$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL
)');

function register(PDO $pdo, string $email, string $password): void
{
    $hash = password_hash($password, PASSWORD_DEFAULT);
    $stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
    $stmt->execute([strtolower(trim($email)), $hash]);
}

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

    if (!$user || !password_verify($password, $user['password_hash'])) {
        return null; // same answer for "no such user" and "wrong password"
    }

    // Upgrade old hashes (e.g. cost 10 from PHP 8.3) on a successful login
    if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
        $new = password_hash($password, PASSWORD_DEFAULT);
        $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')->execute([$new, $user['id']]);
        echo "  (rehashed to {$new})", PHP_EOL;
    }
    return (int) $user['id'];
}

register($pdo, 'Amira@example.co.uk', 'tea-and-biscuits-42');

var_dump(login($pdo, 'amira@example.co.uk', 'tea-and-biscuits-42'));
var_dump(login($pdo, 'amira@example.co.uk', 'coffee-and-cake'));
var_dump(login($pdo, 'nobody@example.co.uk', 'tea-and-biscuits-42'));

// Simulate an older account hashed with cost 10
$old = password_hash('old-password', PASSWORD_BCRYPT, ['cost' => 10]);
$pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)')->execute(['old@example.co.uk', $old]);
echo "old hash: {$old}", PHP_EOL;
var_dump(login($pdo, 'old@example.co.uk', 'old-password'));

Output:

int(1)
NULL
NULL
old hash: $2y$10$2DrGgvG/jIybg8GlcBXcieOz0xf52kz6Y7zboRLbvEeK3GyXzyWJS
  (rehashed to $2y$12$JEtI.XuoDof5tzNdmSzBiuic5JcmQH.kX3ePx1Tb/nzIQJWuq49dy)
int(2)

What each part is doing:

  • VARCHAR(255) for the hash. A bcrypt hash is 60 characters today, but PASSWORD_DEFAULT can change to a longer algorithm in a future PHP version. The PHP manual recommends 255 so you never have to change the column.
  • Prepared statements everywhere. The email goes into ? placeholders, never straight into the SQL. If that's new to you, read my PDO prepared statements post first.
  • The same answer for a wrong email and a wrong password. Both return null. If your login page says "no account with that email", you've told an attacker which emails are registered.
  • password_needs_rehash() on login. This is the part most tutorials skip. The old account was hashed with cost 10. On a successful login, we have the plain password in memory, so we hash it again with today's settings and save it. Over time every active user moves to the stronger hash without a password reset.
  • Emails are trimmed and lower-cased so Amira@example.co.uk and amira@example.co.uk are the same account.

In a real app, after login() returns an ID you'd call session_regenerate_id(true) and store the ID in $_SESSION. Regenerating the session ID on login stops session fixation attacks. My simple math captcha in PHP post shows sessions in action if you haven't used them yet.

Bcrypt's 72-byte limit and Argon2id

Bcrypt only looks at the first 72 bytes of a password. Anything after that is ignored:

<?php
// limits.php: bcrypt's 72-byte limit, Argon2id, and timing
$long = str_repeat('a', 72);
$hash = password_hash($long, PASSWORD_BCRYPT);
var_dump(password_verify($long . 'EXTRA-CHARACTERS', $hash));

// Argon2id has no 72-byte limit (needs PHP built with Argon2 or libsodium)
if (defined('PASSWORD_ARGON2ID')) {
    $a = password_hash($long, PASSWORD_ARGON2ID);
    echo $a, PHP_EOL;
    var_dump(password_verify($long . 'EXTRA-CHARACTERS', $a));
}

// How long does one hash take on this machine?
foreach ([10, 11, 12, 13] as $cost) {
    $t = hrtime(true);
    password_hash('test-password', PASSWORD_BCRYPT, ['cost' => $cost]);
    printf("cost %d: %.0f ms\n", $cost, (hrtime(true) - $t) / 1e6);
}

// Never do this
echo md5('password123'), PHP_EOL;

Output (timings depend on your server):

bool(true)
$argon2id$v=19$m=65536,t=4,p=1$TGdsbFdSMlJKREtsdkY4NA$2ganpjjM4JUHuhF20rgiu8qfxD7BQXX650h8q/XVK1E
bool(false)
cost 10: 58 ms
cost 11: 116 ms
cost 12: 232 ms
cost 13: 468 ms
482c811da5d5b4bc6d497ffa98491e38

The first bool(true) is the surprise: a password with extra characters on the end still passes, because bcrypt never saw them. For normal passwords it doesn't matter, but it does for long passphrases, or if you build a "password" by joining a pepper or a username on the front. 72 bytes can also be fewer than 72 characters if people use emoji or accented letters, since those take several bytes each in UTF-8.

If your PHP has Argon2 support (check with defined('PASSWORD_ARGON2ID')), PASSWORD_ARGON2ID has no such limit, and it's the algorithm current guidance such as OWASP's prefers. Its hashes start with $argon2id$ and also contain their own settings, so password_verify() and password_needs_rehash() work exactly the same. You can switch an existing app over by changing the algorithm in password_hash() and password_needs_rehash(): old bcrypt hashes still verify, and they get upgraded on the next login.

Choosing the cost

The timings show each cost step doubling: about 58 ms at cost 10 and about 230 ms at cost 12 on this machine. Slower is better against someone guessing passwords from a stolen database, but every login pays the price too. A common rule is to aim for something under about 300 ms per hash on your real server. The default of 12 is a sensible choice for most small sites, so I'd only change it after measuring. If you pass a cost, pass the same options to password_needs_rehash() too, or it will think every hash needs upgrading.

What not to do

  • Don't use md5() or sha1(). The last line of the output is md5('password123'). Paste that hash into a search engine and you'll find the password in seconds. These functions are designed to be fast, which is exactly wrong for passwords.
  • Don't make your own salt. The salt option was deprecated in PHP 7.0 and has been ignored since PHP 8.0. password_hash() makes a better one.
  • Don't limit password length to something small, and don't strip characters. Hash exactly what the user typed (validate a sensible maximum, say 128 characters, to stop abuse).
  • Don't email passwords. You can't, if you're hashing properly, and that's the point. Send a reset link instead.

Validating the form itself (required fields, a real email address) is a separate job from hashing. My PHP form validation and sanitisation guide covers that, and the PHP contact form with mail() post shows the same structure for a simpler form.

FAQ

Can you decrypt a password_hash() hash?

No. It's a one-way hash, not encryption. "Decrypt online" sites only work by guessing common passwords, which is why a strong password and a high cost matter.

Why does password_hash() give a different result every time?

Each call creates a new random salt, which is stored inside the hash. password_verify() reads it back out, so different hashes of the same password all verify.

What column type should I use for password_hash()?

VARCHAR(255). It fits today's 60-character bcrypt hashes and longer Argon2id hashes, and leaves room for future algorithms.

Why does password_verify() always return false?

The usual causes are a column that's too short (the hash was cut off when saved), extra spaces from trimming or not trimming, or hashing the password twice. Print strlen($hash) from the database: a bcrypt hash should be exactly 60.

Should I use PASSWORD_DEFAULT or PASSWORD_BCRYPT?

PASSWORD_DEFAULT, so your app picks up stronger defaults in future PHP versions, or PASSWORD_ARGON2ID if your server supports it and you want to choose explicitly.

The PHP manual pages for password_hash() and password_verify() list every option.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes