libro / blog / recuperar-contrasena-php

// login

Recuperación de contraseña en PHP con tokens seguros

Gustavo Arias9 min de lectura

TL;DR Genera un token con random_bytes, guarda en la base de datos solo su hash con una fecha de caducidad corta y envía el token original por correo. Al usarlo, verifícalo, cambia la contraseña con password_hash, invalida el token y responde siempre con el mismo mensaje exista o no la cuenta.

El enlace “¿Olvidaste tu contraseña?” parece un detalle menor, pero es una puerta de entrada a todas las cuentas de tu aplicación. Si está mal hecho, un atacante puede quedarse con cualquier cuenta sin conocer la contraseña. En el libro PHP desde cero hasta experto el sistema de login y el envío de correos de recuperación forman parte del recorrido; aquí tienes el flujo completo y seguro.

El flujo completo

  1. El usuario escribe su email en un formulario.
  2. Si la cuenta existe, el servidor genera un token aleatorio, guarda su hash con una fecha de caducidad y envía por correo un enlace con el token.
  3. En cualquier caso, la pantalla muestra el mismo mensaje: “Si el email está registrado, te enviamos un enlace”.
  4. El usuario abre el enlace, el servidor valida el token y muestra el formulario de nueva contraseña.
  5. Al guardar, se actualiza la contraseña con password_hash, se invalida el token y se cierran las sesiones abiertas.

La tabla de tokens

CREATE TABLE password_resets (
    id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    usuario_id  INT UNSIGNED NOT NULL,
    token_hash  CHAR(64) NOT NULL UNIQUE,   -- sha256 en hexadecimal
    expira_en   DATETIME NOT NULL,
    usado_en    DATETIME NULL,
    creado_en   TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE
);

¿Por qué guardar el hash del token y no el token? Por la misma razón que no guardas contraseñas en texto plano: si alguien lee la base de datos (una copia de seguridad filtrada, una inyección SQL), no podrá usar los tokens pendientes.

Paso 1: solicitar el restablecimiento

<?php
declare(strict_types=1);
require_once __DIR__ . '/config/db.php';      // función db(): PDO
require_once __DIR__ . '/lib/correo.php';     // enviarCorreo($para, $asunto, $html): envoltorio de PHPMailer

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email = trim($_POST['email'] ?? '');

    $stmt = db()->prepare('SELECT id, nombre FROM usuarios WHERE email = ?');
    $stmt->execute([$email]);
    $usuario = $stmt->fetch();

    if ($usuario) {
        // 32 bytes aleatorios criptográficamente seguros → 64 caracteres hex
        $token = bin2hex(random_bytes(32));

        // Invalida solicitudes anteriores y guarda la nueva, válida 30 minutos
        db()->prepare('DELETE FROM password_resets WHERE usuario_id = ?')->execute([$usuario['id']]);
        db()->prepare(
            'INSERT INTO password_resets (usuario_id, token_hash, expira_en)
             VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 MINUTE))'
        )->execute([$usuario['id'], hash('sha256', $token)]);

        $enlace = 'https://tudominio.com/restablecer.php?token=' . $token;
        enviarCorreo($email, 'Restablece tu contraseña',
            '<p>Para crear una nueva contraseña, abre este enlace (válido 30 minutos):</p>'
            . '<p><a href="' . htmlspecialchars($enlace) . '">Restablecer contraseña</a></p>'
            . '<p>Si no lo pediste, ignora este correo.</p>'
        );
    }

    // El mismo mensaje exista o no la cuenta
    $mensaje = 'Si el email está registrado, te enviamos un enlace para restablecer la contraseña.';
}
El enlace usa la URL fija de tu sitio, no $_SERVER['HTTP_HOST']: esa cabecera la controla quien hace la petición, y un atacante podría lograr que el correo apunte a su propio dominio. La función de envío la vimos en cómo enviar correos con PHPMailer.

Paso 2: validar el token y cambiar la contraseña

<?php
declare(strict_types=1);
require_once __DIR__ . '/config/db.php';

$token = $_GET['token'] ?? $_POST['token'] ?? '';

// Busca un token vigente y sin usar
$stmt = db()->prepare(
    'SELECT id, usuario_id FROM password_resets
     WHERE token_hash = ? AND usado_en IS NULL AND expira_en > NOW()'
);
$stmt->execute([hash('sha256', $token)]);
$reset = $stmt->fetch();

if (!$reset) {
    exit('El enlace no es válido o ha caducado. Solicita uno nuevo.');
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $clave = $_POST['password'] ?? '';
    if (mb_strlen($clave) < 10) {
        $error = 'La contraseña debe tener al menos 10 caracteres.';
    } else {
        $pdo = db();
        $pdo->beginTransaction();
        $pdo->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
            ->execute([password_hash($clave, PASSWORD_DEFAULT), $reset['usuario_id']]);
        $pdo->prepare('UPDATE password_resets SET usado_en = NOW() WHERE id = ?')
            ->execute([$reset['id']]);
        $pdo->commit();

        header('Location: login.php?restablecida=1');
        exit;
    }
}
// ...formulario con un campo oculto "token" y el campo "password"

La transacción garantiza que la contraseña y el token se actualizan juntos: no puede quedar una contraseña cambiada con el token todavía reutilizable.

Errores que lo vuelven inseguro

Referencia: OWASP Forgot Password Cheat Sheet. Si quieres repasar cómo se gestionan las sesiones, consulta sesiones y cookies en PHP.

Login y registro completos

El libro PHP desde cero hasta experto incluye un sistema de login y registro con autenticación segura, y el envío de correos de recuperación de contraseña.

Comprar en Amazon