Unit 13 · E-commerce and Web-Related Components

13.5Password Reminders (Reset Passwords)

Step 0: store passwords correctly. Never store plain-text passwords, and never use MD5 or SHA1 for passwords. Use PHP's built-in password hashing, which adds a random salt and uses a slow algorithm:

PHP
// Registration
$hash = password_hash($_POST['password'], PASSWORD_DEFAULT);   // about 60 to 255 characters
$pdo->prepare('INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)')
    ->execute([$name, $email, $hash]);

// Login
$stmt = $pdo->prepare('SELECT id, password_hash, role FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();

if ($user && password_verify($_POST['password'], $user['password_hash'])) {
    session_regenerate_id(true);
    $_SESSION['user_id'] = $user['id'];
    $_SESSION['role']    = $user['role'];
    header('Location: index.php'); exit;
}
$error = 'Invalid email or password';     // same message whether email or password was wrong

Because a hash cannot be reversed, a website can never email you your old password. A proper "password reminder" sends a time-limited, single-use reset link.

Forgot-password flow

User (browser)Website (PHP)DatabaseEmail inbox1POST email (forgot.php)2save hash(token), expiry3email link with the token4click reset.php?token=…5token valid, unused, not expired?6POST new password7save new hash; mark token usedThe old password is never sent: hashes cannot be reversed.
A safe password reset sends a single-use, time-limited link; only a hash of the token is stored

Table:

SQL
CREATE TABLE password_resets (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  token_hash CHAR(64) NOT NULL,
  expires_at DATETIME NOT NULL,
  used TINYINT(1) NOT NULL DEFAULT 0,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

forgot.php (request step):

PHP
<?php
require 'config/db.php'; require 'includes/mail.php';   // mail.php: see 13.8
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email = trim($_POST['email'] ?? '');
    $stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
    $stmt->execute([$email]);
    $user = $stmt->fetch();

    if ($user) {
        $token = bin2hex(random_bytes(32));                      // sent to user
        $hash  = hash('sha256', $token);                         // stored in database
        $pdo->prepare('INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 MINUTE))')
            ->execute([$user['id'], $hash]);

        $link = 'https://yourshop.com/reset.php?token=' . $token;
        send_mail($email, 'Reset your password', 'Click to reset (valid 30 minutes): ' . $link);
    }
    // Same answer whether or not the email exists (prevents account discovery)
    $message = 'If that email is registered, a reset link has been sent.';
}

reset.php (confirm step):

PHP
<?php
require 'config/db.php';
$token = $_GET['token'] ?? $_POST['token'] ?? '';
$hash  = hash('sha256', $token);

$stmt = $pdo->prepare('SELECT id, user_id FROM password_resets WHERE token_hash = ? AND used = 0 AND expires_at > NOW()');
$stmt->execute([$hash]);
$reset = $stmt->fetch();
if (!$reset) exit('This link is invalid or has expired.');

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $new = $_POST['password'] ?? '';
    if (strlen($new) < 8) { $error = 'Password must be 8+ characters'; }
    else {
        $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
            ->execute([password_hash($new, PASSWORD_DEFAULT), $reset['user_id']]);
        $pdo->prepare('UPDATE password_resets SET used = 1 WHERE id = ?')->execute([$reset['id']]);
        $pdo->prepare('DELETE FROM auth_tokens WHERE user_id = ?')->execute([$reset['user_id']]); // sign out remembered devices (13.6)
        header('Location: login.php?reset=1'); exit;
    }
}
// ... show the form with a hidden input name='token' value='...'

Additional good practice: rate-limit reset requests; send a "your password was changed" email; require a strong password; never log tokens.