PHP cookbook · Intermediate

Build a secure password reset flow in PHP

Let users reset forgotten passwords without enabling account takeover or email enumeration.

Approach

Generate a long random token, store only its hash with a short expiry (e.g., 30 minutes), email a link containing the raw token, and always show the same message whether the email exists or not. On reset, look up by hash, check expiry, set the new password with password_hash(), delete all reset tokens for the user and invalidate existing sessions.

Solution

<?php
declare(strict_types=1);

function requestReset(PDO $pdo, Mailer $mailer, string $email): void
{
    $user = findUserByEmail($pdo, $email);
    if ($user) {
        $token = bin2hex(random_bytes(32));
        $pdo->prepare('INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, NOW() + INTERVAL \'30 minutes\')')
            ->execute([$user['id'], hash('sha256', $token)]);
        $mailer->sendResetLink($user['email'], "https://example.com/reset?token={$token}");
    }
    // Same response either way (and similar timing — consider queueing the email)
}

function resetPassword(PDO $pdo, string $token, #[\SensitiveParameter] string $newPassword): bool
{
    $stmt = $pdo->prepare('SELECT user_id FROM password_resets WHERE token_hash = ? AND expires_at > NOW()');
    $stmt->execute([hash('sha256', $token)]);
    $userId = $stmt->fetchColumn();
    if (!$userId || strlen($newPassword) < 12) {
        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->prepare('DELETE FROM sessions WHERE user_id = ?')->execute([$userId]);   // log out everywhere
    $pdo->commit();
    return true;
}

Pitfalls

  • Storing raw tokens (a DB leak lets attackers reset any account).
  • 'No account with that email' messages enabling enumeration.
  • Tokens that never expire or can be reused.

Variations

  • Rate-limit reset requests per email and IP.
  • Notify users by email after a successful password change.

Further reading: OWASP Forgot Password Cheat Sheet

Reference: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html

More PHP recipes