↓Ir al contenido

Hashes — Cómo Guardar Contraseñas de Forma Segura

Esta semana empiezas a construir con NestJS. Uno de los primeros módulos que vas a implementar maneja usuarios — y en el momento en que hay usuarios, hay contraseñas. Este material cubre el mecanismo que hace posible verificar una contraseña sin guardarla nunca en texto legible.


1. Qué es una Función Hash #

Una función hash toma una entrada de cualquier tamaño y produce una salida de longitud fija. Tiene tres propiedades fundamentales:

"hola123"          → 5e8848f1e8f7b7c1a2d4ef03e98cf345a...
"hola124"          → 9a3c1f72b4d890e23a7b1cd5f48e2d01b...
"hola123"          → 5e8848f1e8f7b7c1a2d4ef03e98cf345a...  ← mismo input, mismo output
PropiedadDescripción
DeterministaEl mismo input siempre produce el mismo output
UnidireccionalNo se puede reconstruir el input a partir del output
Efecto avalanchaUn cambio mínimo en el input cambia completamente el output

La propiedad unidireccional es lo que hace útil el hashing para contraseñas: la base de datos guarda el hash, nunca la contraseña. Cuando el usuario inicia sesión, se hashea la contraseña que escribió y se compara con el hash guardado.

Registro:       "miContraseña123" ──hash──▶ "5e884898da..." → guarda en DB
Inicio sesión:  "miContraseña123" ──hash──▶ "5e884898da..." → compara → ✅
Inicio sesión:  "miContraseña124" ──hash──▶ "abc3f92d1b..." → compara → ❌

2. Por Qué No Guardar Contraseñas en Texto Plano #

Si la base de datos se filtra — ya sea por un ataque, un backup mal configurado o un error humano — todas las contraseñas quedan expuestas. Y como la mayoría de personas reutiliza contraseñas, eso compromete también sus cuentas en otros servicios.

Con hashing, una filtración de la base de datos expone los hashes, no las contraseñas. El atacante tendría que “adivinar” cada contraseña y verificarla contra el hash — lo que es costoso si el algoritmo de hashing está bien elegido.


3. Por Qué MD5 y SHA-256 No Sirven para Contraseñas #

MD5, SHA-1, SHA-256 y SHA-512 son funciones hash diseñadas para ser extremadamente rápidas — su propósito original es verificar integridad de archivos, no proteger contraseñas.

Un hardware moderno puede calcular:

  • MD5: ~10 mil millones de hashes por segundo (GPU)
  • SHA-256: ~2 mil millones de hashes por segundo (GPU)

Eso significa que un atacante con el hash puede probar 2.000.000.000 contraseñas por segundo con un ataque de diccionario.

SHA-256("password")     → calculado en < 1 microsegundo
SHA-256("Password1")    → calculado en < 1 microsegundo
SHA-256("P@ssw0rd!")    → calculado en < 1 microsegundo
...probar un diccionario de 10 millones de contraseñas: menos de 1 segundo

Los algoritmos diseñados para contraseñas hacen exactamente lo contrario: son intencionalmente lentos y costosos en memoria. Un hash que tarda 100 milisegundos es imperceptible para el usuario al iniciar sesión, pero hace que un ataque de diccionario tarde siglos.


4. bcrypt — El Estándar para Contraseñas #

bcrypt es el algoritmo de hashing de contraseñas más usado en producción. Fue diseñado en 1999 específicamente para este propósito y tiene dos características clave:

Salt automático #

Un salt es un valor aleatorio único que se mezcla con la contraseña antes de hashear. Esto garantiza que dos usuarios con la misma contraseña tengan hashes distintos:

"hola123" + salt_A → "2b$12$KIXyLQ..." ← usuario 1
"hola123" + salt_B → "2b$12$zQ8mRt..." ← usuario 2

Sin salt, si dos usuarios tienen la misma contraseña, sus hashes son idénticos — lo que delata que comparten contraseña. Con salt, cada hash es único incluso con contraseñas repetidas.

bcrypt genera e incorpora el salt automáticamente. No tienes que manejarlo tú.

Work factor (salt rounds) #

El work factor (llamado saltRounds en la librería) controla cuánto trabajo computacional se necesita para calcular el hash. Se especifica como potencia de 2:

saltRoundsTiempo aproximado (hardware moderno)Uso recomendado
10~100 msDesarrollo y producción estándar
12~400 msAplicaciones de alta seguridad
14~1.5 sDemasiado lento para la mayoría

saltRounds: 10 es el valor por defecto recomendado. A medida que el hardware mejora, puedes subir el número para mantener la misma dificultad para un atacante.

Anatomía de un hash bcrypt #

$2b$10$KIXyLQniVFoRVKqVHFkL6.8bfVfAe3G5pYkuJBYMDLb3LZl92gQ6O

 $2b  → versión del algoritmo
 $10  → work factor (2^10 iteraciones)
 KIXyLQniVFoRVKqVHFkL6. → los primeros 22 chars son el salt
 8bfVfAe3G5pYkuJBYMDLb3LZl92gQ6O → el hash real

Todo está en una sola string. La librería sabe cómo extraer el salt y el work factor para verificar una contraseña — tú solo guardas esa string en la DB.


5. Implementación en TypeScript #

Instalación #

npm install bcryptjs
npm install -D @types/bcryptjs

bcryptjs es una implementación en JavaScript puro — más compatible entre plataformas que bcrypt (que usa bindings nativos en C++). Para la mayoría de proyectos, la diferencia de rendimiento es irrelevante.

Hashear una contraseña #

import * as bcrypt from "bcryptjs";

const SALT_ROUNDS = 10;

async function hashearContrasena(contrasena: string): Promise<string> {
  return bcrypt.hash(contrasena, SALT_ROUNDS);
}

// Uso
const hash = await hashearContrasena("miContraseña123");
console.log(hash);
// "$2b$10$KIXyLQniVFoRVKqVHFkL6.8bfVfAe3G5pYkuJBYMDLb3LZl92gQ6O"
// Cada vez que lo llamas, el hash es diferente — el salt cambia

Verificar una contraseña #

async function verificarContrasena(
  contrasenaIngresada: string,
  hashGuardado: string
): Promise<boolean> {
  return bcrypt.compare(contrasenaIngresada, hashGuardado);
}

// Uso — flujo de login
const esCorrecta = await verificarContrasena("miContraseña123", hashEnDB);
// true si coincide, false si no

if (!esCorrecta) {
  throw new Error("Credenciales incorrectas");
}

bcrypt.compare() extrae el salt del hash guardado, lo usa para hashear la contraseña ingresada y compara los resultados. Nunca expones la contraseña original.

El flujo completo — registro y login #

// ──────────────────────────────────────────
// REGISTRO
// ──────────────────────────────────────────
async function registrarUsuario(email: string, contrasena: string) {
  // 1. Hashear antes de guardar
  const hash = await bcrypt.hash(contrasena, 10);

  // 2. Guardar el hash, nunca la contraseña
  const usuario = await prisma.usuario.create({
    data: { email, contrasena: hash },
  });

  return usuario;
}

// ──────────────────────────────────────────
// LOGIN
// ──────────────────────────────────────────
async function iniciarSesion(email: string, contrasena: string) {
  // 1. Buscar usuario por email
  const usuario = await prisma.usuario.findUnique({ where: { email } });
  if (!usuario) throw new Error("Credenciales incorrectas");

  // 2. Comparar con el hash guardado
  const esValida = await bcrypt.compare(contrasena, usuario.contrasena);
  if (!esValida) throw new Error("Credenciales incorrectas");

  return usuario;
}

En el login, el mensaje de error es intencionalmente genérico: “Credenciales incorrectas” — no “Usuario no encontrado” ni “Contraseña incorrecta”. Dar un error específico le dice al atacante cuál de los dos datos es correcto.

El modelo en Prisma #

model Usuario {
  id         Int      @id @default(autoincrement())
  email      String   @unique
  contrasena String   // guarda el hash de bcrypt (~60 caracteres)
  creadoEn   DateTime @default(now()) @map("creado_en")

  @@map("usuarios")
}

El campo contrasena es un String estándar — bcrypt siempre produce una string de 60 caracteres.


6. Lo que Nunca Debes Hacer #

// ❌ Guardar en texto plano
await prisma.usuario.create({
  data: { email, contrasena: req.body.contrasena },
});

// ❌ Usar MD5 o SHA para contraseñas
import { createHash } from "crypto";
const hash = createHash("md5").update(contrasena).digest("hex");  // peligroso

// ❌ Comparar strings directamente
if (usuario.contrasena === req.body.contrasena) { ... }  // nunca funciona con hashes

// ❌ Loggear la contraseña en cualquier punto del flujo
console.log("Contraseña recibida:", req.body.contrasena);

// ❌ Devolver el hash al cliente en la respuesta
res.json({ id: usuario.id, email, contrasena: usuario.contrasena });
// ✅ Hashear antes de guardar
const hash = await bcrypt.hash(contrasena, 10);
await prisma.usuario.create({ data: { email, contrasena: hash } });

// ✅ Usar bcrypt.compare() — nunca comparar strings
const valida = await bcrypt.compare(contrasena, usuario.contrasena);

// ✅ Excluir el hash de las respuestas
const { contrasena: _, ...usuarioPublico } = usuario;
res.json(usuarioPublico);

Preguntas Frecuentes #

¿Por qué el hash es diferente cada vez que llamo a bcrypt.hash() con la misma contraseña? #

Porque bcrypt genera un salt aleatorio nuevo en cada llamada. El salt se incorpora en la string del hash, así que cuando verificas con bcrypt.compare(), la librería lo extrae y lo usa. No necesitas guardar el salt por separado — ya está dentro del hash.

¿Puedo hashear en el frontend y enviar el hash al backend? #

No. Si el hash se convierte en “la contraseña” que viaja por la red, un atacante que intercepte la comunicación puede usar el hash directamente para autenticarse — sin necesitar la contraseña original. El hashing siempre ocurre en el servidor, después de recibir la contraseña por HTTPS.

¿Qué pasa si dos usuarios tienen la misma contraseña? #

Sus hashes son distintos gracias al salt aleatorio. Nadie mirando la base de datos puede saber que dos usuarios tienen la misma contraseña.

¿Cuándo debo subir el saltRounds? #

Cuando el hardware mejora y el tiempo de hash con el valor actual cae por debajo de ~100 ms. Puedes re-hashear las contraseñas existentes la próxima vez que el usuario inicia sesión con éxito: verificas con el hash viejo y guardas el nuevo.

¿bcrypt tiene algún límite de longitud de contraseña? #

Sí — bcrypt solo procesa los primeros 72 bytes de la contraseña. Una contraseña de más de 72 caracteres no es más segura que una de 72. En la práctica, esto no es un problema para la mayoría de usuarios, pero si quieres soportar contraseñas más largas, puedes aplicar un SHA-256 primero y luego bcrypt sobre el resultado.

¿Argon2 es mejor que bcrypt? #

Sí — Argon2 (ganador del Password Hashing Competition de 2015) es más moderno y resistente a ataques con hardware especializado (GPUs, ASICs). En Node.js existe como paquete argon2. La recomendación actual para nuevos proyectos es usar Argon2id, pero bcrypt sigue siendo seguro y es la opción más compatible y documentada en el ecosistema de Node.js.


Resumen #

ConceptoClave
Hash unidireccionalNo se puede revertir — solo se puede verificar comparando
MD5 / SHA para contraseñasPeligroso — son demasiado rápidos, permiten ataques de diccionario en segundos
bcryptDiseñado para contraseñas — intencionalmente lento y costoso
SaltValor aleatorio único por hash — bcrypt lo genera y lo embebe automáticamente
saltRounds: 10Valor por defecto recomendado — ~100 ms, suficiente para la mayoría de casos
bcrypt.hash()Hashea la contraseña — usa siempre await, es asíncrono
bcrypt.compare()Verifica — nunca comparar strings directamente
Error genérico en login“Credenciales incorrectas” — no revelar si el email o la contraseña es lo incorrecto
Excluir hash de respuestasDesestructurar y omitir contrasena antes de res.json()
Hash siempre en el servidorNunca en el frontend — el hash no puede ser “la contraseña que viaja por la red”