A Login, End to End
A login in four steps: find the user, verify the password hash, regenerate the session id, store only the user id.
password_verify(string $password, string $hash): boolA login answers one question: does this person know the password for this account? Answering it safely takes four steps, and the order matters.
Store a hash, never a password
password_hash() turns a password into a one-way hash. PASSWORD_DEFAULT is bcrypt, with a cost of 12 since PHP 8.4. The hash carries its own random salt, so the same password hashes differently every time and a stolen table is not a list of passwords. md5() and sha1() are not password hashes. They are far too fast to be one.
Look up, then verify
Find the row by email or username with a prepared statement: a query whose values are sent to the database separately from the SQL text, covered in the next stage. Then call password_verify() with the submitted password and the stored hash. It redoes the work and compares the result in constant time.
Answer the same way both times
Wrong password and no such account must give the same message and take the same work, or the form becomes a way of discovering who has an account. Verify against a throwaway hash when the lookup found nothing.
Then the session
Call session_regenerate_id(true), then put only the user id in the session. A name or a role copied in at login goes stale; read those from the database on each request, using the id.
Example
When to use it
- Build registration and login on a plain-PHP site with no framework.
- Move an old table off md5 hashes without resetting everyone's password.
- Stop a login form from revealing which email addresses are registered.
- Raise the bcrypt cost and upgrade stored hashes as people sign in.
More examples
Registration
Sign-up in full. The plain password never leaves this script: it is hashed and the hash is what gets stored, through a prepared statement so the email address cannot alter the query. A length floor beats a complexity rule, because length is what makes guessing expensive.
<?php
declare(strict_types=1);
// registration handler
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false || strlen($password) < 12) {
http_response_code(422);
exit('Enter a valid email address and a password of at least 12 characters.');
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$stmt->execute([$email, $hash]);The login handler
The four steps in one file, in order. password_verify() always runs, even for an unknown email, so the response time does not reveal which addresses exist, and the one message covers both failures. Only after it succeeds does the id change and the user id go into the session.
<?php
declare(strict_types=1);
session_start();
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
// UNKNOWN_USER_HASH is a real hash of a random value, made once and kept in
// config. Verifying against it costs the same as verifying a real account.
$hash = $user['password_hash'] ?? UNKNOWN_USER_HASH;
if (!password_verify($password, $hash) || $user === false) {
http_response_code(401);
exit('Email or password is incorrect.');
}
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
$_SESSION['started_at'] = time();
$_SESSION['last_seen'] = time();
header('Location: /account.php', true, 303);
exit;Upgrading an old hash on the way in
The login handler is the only moment you hold the plain password, so it is the only moment you can re-hash it. password_needs_rehash() reports true when the stored hash uses an older algorithm or a lower cost than today's default, which happens to every bcrypt hash made before PHP 8.4 raised the default cost to 12. Over a few weeks the whole table upgrades itself.
<?php
declare(strict_types=1);
// Still inside the login handler, after the password matched.
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$stmt = $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
$stmt->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
}
Discussion