Skip to content

Contraseñas y autenticación ​

El login es la puerta principal de cualquier aplicación. Si guardas mal las contraseñas, una sola filtración de la base de datos expone a todos tus usuarios (y como la gente reutiliza contraseñas, también sus correos y bancos). En esta lección aprenderás a guardar contraseñas como un profesional, a frenar ataques de fuerza bruta y a diseñar una recuperación de cuenta segura.

Concepto ​

Cifrar no es lo mismo que hashear ​

  • Cifrar es reversible: con la clave correcta recuperas el original. Sirve para datos que necesitas leer después (un número de teléfono, un token de API de terceros).
  • Hashear es de un solo sentido: conviertes la contraseña en una "huella" y no hay forma de volver atrás. Para verificar, hasheas lo que escribe el usuario y comparas huellas.

Las contraseñas siempre se hashean, nunca se cifran. Ni tú, como administrador, deberías poder verlas.

¿Por qué no MD5 o SHA-256? ​

MD5 y SHA-256 son hashes rápidos, diseñados para verificar archivos. Una tarjeta gráfica moderna calcula miles de millones de hashes MD5 por segundo: una contraseña de 8 caracteres cae en horas. Además, existen tablas precalculadas ("rainbow tables") con millones de contraseñas comunes.

Los algoritmos para contraseñas (bcrypt, Argon2id, scrypt) son lentos a propósito y usan una sal (salt): un valor aleatorio distinto para cada usuario, que hace que dos contraseñas iguales produzcan hashes distintos e inutiliza las rainbow tables.

Algoritmo¿Para contraseñas?Comentario
Texto plano❌ JamásDelito de negligencia en muchos países
base64❌No es ni siquiera cifrado, es codificación
MD5 / SHA-1 / SHA-256❌Demasiado rápidos
bcrypt✅Estándar probado; máximo 72 bytes
Argon2id✅✅Recomendado por OWASP; resistente a GPU

Ejemplo: registro y login en PHP ​

PHP trae todo lo necesario con password_hash y password_verify. Genera la sal automáticamente y la guarda dentro del propio hash.

php
<?php
require 'db.php';

$email    = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';

if (!$email) {
    exit('Correo inválido');
}

// Política razonable: longitud mínima, sin reglas absurdas de símbolos
if (mb_strlen($password) < 10) {
    exit('La contraseña debe tener al menos 10 caracteres');
}

// ✅ PASSWORD_DEFAULT usa bcrypt hoy y se actualizará solo en futuras versiones
$hash = password_hash($password, PASSWORD_DEFAULT);
// O explícitamente Argon2id (si tu PHP está compilado con soporte):
// $hash = password_hash($password, PASSWORD_ARGON2ID);

try {
    $stmt = $pdo->prepare('INSERT INTO usuarios (email, password_hash) VALUES (?, ?)');
    $stmt->execute([$email, $hash]);
} catch (PDOException $e) {
    // Código 23000 = violación de UNIQUE (correo repetido)
    exit('No se pudo completar el registro');
}

echo 'Cuenta creada';
php
<?php
session_start();
require 'db.php';

$email    = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';

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

// ✅ password_verify compara en tiempo constante
if ($usuario && password_verify($password, $usuario['password_hash'])) {

    // Si el algoritmo o el coste cambiaron, actualizamos el hash de forma transparente
    if (password_needs_rehash($usuario['password_hash'], PASSWORD_DEFAULT)) {
        $nuevo = password_hash($password, PASSWORD_DEFAULT);
        $pdo->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
            ->execute([$nuevo, $usuario['id']]);
    }

    session_regenerate_id(true); // evita fijación de sesión (lección 06)
    $_SESSION['usuario_id'] = $usuario['id'];
    header('Location: /panel.php');
    exit;
}

// ✅ Mensaje genérico: no revelamos si el correo existe
$error = 'Correo o contraseña incorrectos';
sql
CREATE TABLE usuarios (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  -- 255 caracteres: los hashes pueden crecer si cambia el algoritmo
  password_hash VARCHAR(255) NOT NULL,
  creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Un hash bcrypt se ve así: $2y$12$Qz1.... El 2y indica el algoritmo, 12 el coste (2^12 iteraciones) y el resto es la sal más el hash.

Ejemplo: registro y login en Node ​

bash
npm install bcrypt
# o, para Argon2id:
npm install argon2
js
import bcrypt from 'bcrypt';

const COSTE = 12; // ~250 ms por hash en un servidor típico

app.post('/registro', async (req, res) => {
  const { email, password } = req.body;
  if (typeof password !== 'string' || password.length < 10) {
    return res.status(400).json({ error: 'Mínimo 10 caracteres' });
  }

  const hash = await bcrypt.hash(password, COSTE); // genera la sal automáticamente
  await pool.execute('INSERT INTO usuarios (email, password_hash) VALUES (?, ?)', [email, hash]);
  res.status(201).json({ ok: true });
});

app.post('/login', async (req, res) => {
  const { email = '', password = '' } = req.body;
  const [filas] = await pool.execute('SELECT id, password_hash FROM usuarios WHERE email = ?', [email]);
  const usuario = filas[0];

  // Si el usuario no existe, comparamos contra un hash falso para tardar lo mismo
  const hash = usuario?.password_hash ?? '$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x7f1rM3zZ5VJrYzE2Wc1n8aTqvKqxW';
  const ok = await bcrypt.compare(password, hash);

  if (!usuario || !ok) {
    return res.status(401).json({ error: 'Correo o contraseña incorrectos' });
  }

  req.session.regenerate(err => {
    if (err) return res.status(500).end();
    req.session.usuarioId = usuario.id;
    res.json({ ok: true });
  });
});
js
import argon2 from 'argon2';

// Argon2id con parámetros por defecto (recomendados por OWASP)
const hash = await argon2.hash(password, { type: argon2.argon2id });

// Verificar: el orden es (hash, contraseña)
const ok = await argon2.verify(hash, password);

Error común

Crear tu propio esquema tipo sha256($password . 'mi_sal_secreta'). Sigue siendo rápido, la sal es la misma para todos y si alguien ve tu código, se acabó. Usa password_hash, bcrypt o argon2.

Frenar la fuerza bruta ​

Aunque los hashes sean lentos, un atacante puede probar miles de contraseñas comunes contra tu formulario de login. Necesitas limitar los intentos.

php
<?php
// Tabla: intentos_login (id, email, ip, creado_en)
function demasiadosIntentos(PDO $pdo, string $email, string $ip): bool
{
    $stmt = $pdo->prepare(
        'SELECT COUNT(*) FROM intentos_login
         WHERE (email = ? OR ip = ?) AND creado_en > (NOW() - INTERVAL 15 MINUTE)'
    );
    $stmt->execute([$email, $ip]);
    return $stmt->fetchColumn() >= 5; // máximo 5 intentos cada 15 minutos
}

function registrarIntento(PDO $pdo, string $email, string $ip): void
{
    $pdo->prepare('INSERT INTO intentos_login (email, ip) VALUES (?, ?)')->execute([$email, $ip]);
}

$ip = $_SERVER['REMOTE_ADDR'];
if (demasiadosIntentos($pdo, $email, $ip)) {
    http_response_code(429);
    exit('Demasiados intentos. Espera 15 minutos.');
}

// ...si el login falla:
registrarIntento($pdo, $email, $ip);
// ...si el login es correcto, puedes borrar los intentos de ese correo
js
import rateLimit from 'express-rate-limit';

const limiteLogin = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutos
  limit: 5,                 // 5 intentos por IP en la ventana
  standardHeaders: 'draft-7',
  legacyHeaders: false,
  message: { error: 'Demasiados intentos. Espera 15 minutos.' },
});

app.post('/login', limiteLogin, loginHandler);

Detrás de un proxy

Si tu app está detrás de Cloudflare, Nginx o un balanceador, REMOTE_ADDR será la IP del proxy. En Express configura app.set('trust proxy', 1); en PHP confía en X-Forwarded-For solo si viene de tu proxy conocido.

Recuperación de contraseña segura ​

Es un punto débil frecuente. El flujo correcto:

  1. El usuario escribe su correo. Respondes siempre "Si el correo existe, te enviamos un enlace" (no revelas si existe).
  2. Generas un token aleatorio largo, guardas su hash con fecha de expiración (por ejemplo, 1 hora).
  3. Envías por correo el enlace con el token original.
  4. Al recibirlo, hasheas el token, lo buscas, verificas que no haya expirado, cambias la contraseña y borras el token (un solo uso).
  5. Cierras las demás sesiones del usuario.
php
<?php
// Paso 2: generar token
$token = bin2hex(random_bytes(32));            // lo que va en el enlace
$tokenHash = hash('sha256', $token);           // lo que guardas (si roban la BD, no sirve)

$pdo->prepare('INSERT INTO resets (usuario_id, token_hash, expira) VALUES (?, ?, NOW() + INTERVAL 1 HOUR)')
    ->execute([$usuario['id'], $tokenHash]);

$enlace = 'https://tusitio.com/restablecer.php?token=' . $token;
// enviarCorreo($email, $enlace);

// Paso 4: validar
$stmt = $pdo->prepare('SELECT usuario_id FROM resets WHERE token_hash = ? AND expira > NOW()');
$stmt->execute([hash('sha256', $_POST['token'] ?? '')]);
$reset = $stmt->fetch();
if (!$reset) {
    exit('Enlace inválido o expirado');
}
$pdo->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
    ->execute([password_hash($_POST['password'], PASSWORD_DEFAULT), $reset['usuario_id']]);
$pdo->prepare('DELETE FROM resets WHERE usuario_id = ?')->execute([$reset['usuario_id']]);

¿Por qué SHA-256 aquí sí vale?

El token es aleatorio de 256 bits, no una contraseña elegida por una persona. No hay diccionario que probar, así que un hash rápido es suficiente.

Buenas prácticas de autenticación ​

  • Longitud > complejidad: exige mínimo 10-12 caracteres y permite frases largas. Las reglas tipo "una mayúscula, un símbolo" producen Password1!.
  • Contraseñas filtradas: puedes rechazar contraseñas que aparecen en filtraciones con la API gratuita de Have I Been Pwned (k-anonymity: solo envías los 5 primeros caracteres del SHA-1).
  • Autenticación en dos pasos (2FA): con TOTP (Google Authenticator) usando librerías como pragmarx/google2fa (PHP) u otplib (Node). Ofrécelo al menos a los administradores.
  • Mensajes genéricos en login, registro y recuperación.
  • Notifica por correo los cambios de contraseña o correo.
  • Nunca envíes contraseñas por correo, ni siquiera al registrarse.

Ejercicios ​

  1. Tienes una base de datos heredada con contraseñas en MD5. ¿Cómo migras a password_hash sin obligar a todos a cambiar su contraseña?
  2. Escribe en Node una función cambiarPassword(usuarioId, actual, nueva) que verifique la contraseña actual antes de cambiarla.
  3. ¿Por qué el mensaje "Este correo no está registrado" es un problema de seguridad?
Ver solución
  1. Migración progresiva: al hacer login, si el hash guardado tiene 32 caracteres (MD5), compruebas con md5(); si coincide, generas el nuevo hash y lo guardas. Mejor aún, envuelve los MD5 existentes de inmediato: password_hash($md5Viejo) y verifica con password_verify(md5($password), $hash) hasta el siguiente login, para no dejar MD5 desnudos en la BD.
php
<?php
if (strlen($u['password_hash']) === 32) {          // formato MD5 heredado
    if (hash_equals($u['password_hash'], md5($password))) {
        $nuevo = password_hash($password, PASSWORD_DEFAULT);
        $pdo->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')->execute([$nuevo, $u['id']]);
        $loginOk = true;
    }
} else {
    $loginOk = password_verify($password, $u['password_hash']);
}
  1. Cambio de contraseña:
js
async function cambiarPassword(usuarioId, actual, nueva) {
  const [filas] = await pool.execute('SELECT password_hash FROM usuarios WHERE id = ?', [usuarioId]);
  if (!filas[0] || !(await bcrypt.compare(actual, filas[0].password_hash))) {
    throw new Error('La contraseña actual no es correcta');
  }
  if (nueva.length < 10) throw new Error('Mínimo 10 caracteres');

  const hash = await bcrypt.hash(nueva, 12);
  await pool.execute('UPDATE usuarios SET password_hash = ? WHERE id = ?', [hash, usuarioId]);
}
  1. Permite la enumeración de usuarios: un atacante descubre qué correos tienen cuenta y luego los ataca con fuerza bruta o phishing dirigido.

Resumen ​

  • Las contraseñas se hashean, nunca se cifran ni se guardan en texto plano.
  • Usa password_hash/password_verify en PHP y bcrypt o argon2 en Node.
  • MD5, SHA-1 y SHA-256 no sirven para contraseñas.
  • Limita los intentos de login y responde con mensajes genéricos.
  • La recuperación usa tokens aleatorios, de un solo uso, con expiración y guardados como hash.
  • Ofrece 2FA, sobre todo a administradores.

Academia Web — aprende, copia y construye.