People forget passwords. A reset flow lets them choose a new one without you ever knowing the old one. It must be designed carefully, because it is a way to take over an account.
The flow
- The user asks for a reset and enters their email.
- You always answer with the same message, whether or not the email exists.
- If it does, you create a random token, store its hash with an expiry time, and email a link that contains the token.
- The user opens the link. You check the token (matches, not expired, not used).
- The user chooses a new password. You update the hash, delete the token and end other sessions.
The table
CREATE TABLE password_resets (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Create the token
<?php
function createResetToken(PDO $pdo, string $email): ?string
{
$statement = $pdo->prepare("SELECT id FROM users WHERE email = :email");
$statement->execute(["email" => strtolower(trim($email))]);
$userId = $statement->fetchColumn();
if ($userId === false) {
return null; // the caller still shows the same message
}
$token = bin2hex(random_bytes(32));
$pdo->prepare("DELETE FROM password_resets WHERE user_id = ?")->execute([$userId]);
$pdo->prepare("INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, ?)")
->execute([$userId, hash("sha256", $token), date("Y-m-d H:i:s", time() + 3600)]);
return $token; // this goes into the emailed link, and nowhere else
}
$pdo = require __DIR__ . "/db.php";
$pdo->exec("CREATE TABLE password_resets (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL)");
$token = createResetToken($pdo, "ana@example.com");
echo strlen($token) . " characters in the link token\n";
var_dump(createResetToken($pdo, "nobody@example.com"));
64 characters in the link token
NULL
The database stores only hash("sha256", $token). If someone reads the table, they cannot use the hashes to reset passwords. The link is the secret, and the link goes only to the user’s inbox.
The page that handles the email
<?php
// forgot-password.php: always the same answer
$token = createResetToken($pdo, (string) ($_POST["email"] ?? ""));
if ($token !== null) {
$link = "https://example.com/reset-password.php?token=" . urlencode($token);
// send the email with $link (see the note below)
}
echo "If that address has an account, we have sent a reset link.";
Use your configured domain in the link (https://example.com/...), never $_SERVER["HTTP_HOST"], which a visitor can fake (host header injection).
Check the token
<?php
function userIdForResetToken(PDO $pdo, string $token): ?int
{
$statement = $pdo->prepare("SELECT user_id, expires_at FROM password_resets WHERE token_hash = :hash");
$statement->execute(["hash" => hash("sha256", $token)]);
$row = $statement->fetch();
if ($row === false || strtotime($row["expires_at"]) < time()) {
return null;
}
return (int) $row["user_id"];
}
function resetPassword(PDO $pdo, string $token, string $newPassword): bool
{
$userId = userIdForResetToken($pdo, $token);
if ($userId === null || mb_strlen($newPassword) < 10) {
return false;
}
$pdo->beginTransaction();
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
->execute([password_hash($newPassword, PASSWORD_DEFAULT), $userId]);
$pdo->prepare("DELETE FROM password_resets WHERE user_id = ?")->execute([$userId]);
$pdo->commit();
return true;
}
$pdo = require __DIR__ . "/db.php";
$pdo->exec("CREATE TABLE password_resets (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL)");
$token = bin2hex(random_bytes(32));
$pdo->prepare("INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, ?)")
->execute([1, hash("sha256", $token), date("Y-m-d H:i:s", time() + 3600)]);
var_dump(resetPassword($pdo, "wrong-token", "a brand new passphrase"));
var_dump(resetPassword($pdo, $token, "a brand new passphrase"));
var_dump(resetPassword($pdo, $token, "another passphrase here")); // the token was one-time
$hash = $pdo->query("SELECT password_hash FROM users WHERE id = 1")->fetchColumn();
var_dump(password_verify("a brand new passphrase", $hash));
bool(false)
bool(true)
bool(false)
bool(true)
The token worked once. The password was changed with a new hash, and the token row was deleted in the same transaction, so a second use fails.
Security rules for resets
- Random, long tokens from
random_bytes(). Never userand(),uniqid()or the time. - Hash the token in the database.
- Short expiry (30 to 60 minutes) and one use only.
- Same answer for known and unknown emails.
- Rate limit reset requests per IP and per account.
- HTTPS for the link and the form.
- After a successful reset, end all other sessions (increase the user’s
session_version, see Logout), delete remember-me tokens, and email the user that their password changed. - Do not log the token.
- Do not log the user in automatically from the link without asking for the new password.
Sending the email
Use a mail library (such as PHPMailer or Symfony Mailer, installed with Composer) and an SMTP service. The built-in mail() function depends on server setup and often lands in spam. Never put user input into mail headers without removing line breaks (header injection).