Saltar al contenido principal

Hashing de contraseñas

Nunca se deben almacenar contraseñas en texto plano. Si la base de datos se filtra, las contraseñas quedan expuestas directamente. El objetivo es guardar una representación irreversible de la contraseña que permita verificarla sin revelarla.

Conceptos previos

  • Hash: Función unidireccional. A partir del hash no se puede recuperar el original.
  • Salt: Valor aleatorio único añadido a cada contraseña antes de hashearla. Evita que dos usuarios con la misma contraseña tengan el mismo hash y anula las tablas rainbow.
  • Factor de coste (work factor): Controla cuántas iteraciones realiza el algoritmo. Un factor más alto hace el hash más lento, lo que dificulta los ataques de fuerza bruta. Debe ajustarse con el tiempo conforme el hardware mejora.

¿Por qué no usar MD5, SHA-1 o SHA-256?

Son algoritmos diseñados para ser extremadamente rápidos (útiles para verificar integridad de archivos, firmas digitales, etc.), lo que los hace pésimos para contraseñas. Un atacante con una GPU moderna puede probar miles de millones de combinaciones por segundo.

Para contraseñas necesitamos algoritmos deliberadamente lentos y resistentes a paralelización.

Algoritmos para hashing de contraseñas

bcrypt

El estándar más extendido históricamente. Diseñado en 1999 específicamente para contraseñas. Incorpora salt automático y un factor de coste ajustable (número de rondas).

Limitación importante: Trunca la contraseña a 72 bytes. Contraseñas más largas son ignoradas a partir de ese punto.

import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12; // Factor de coste: más alto = más lento y seguro

// Registrar usuario
export const hashPassword = async (plainPassword) => {
return await bcrypt.hash(plainPassword, SALT_ROUNDS);
};

// Verificar en login
export const verifyPassword = async (plainPassword, storedHash) => {
return await bcrypt.compare(plainPassword, storedHash);
};

Cuándo usarlo: Cuando necesitas compatibilidad amplia o trabajas en un entorno donde las dependencias nativas son un problema (usa bcryptjs en ese caso, implementación pura en JS).

Tenemos dos opciones para instalar:

  • bcrypt: Implementación con binarios nativos. Más rápida.
  • bcryptjs: Implementación pura en JavaScript. Sin dependencias nativas, más portable.
npm install bcrypt          # binarios nativos
npm install bcryptjs # implementación en JS

argon2

Argon2 es el ganador del Password Hashing Competition (2015), el concurso oficial que buscó el sucesor moderno de bcrypt. Actualmente considerado el algoritmo recomendado para nuevos proyectos.

Existen tres variantes:

  • argon2d: Resistente a ataques de GPU. Para aplicaciones con baja amenaza de ataques de canal lateral.
  • argon2i: Resistente a ataques de canal lateral. Para entornos donde el atacante puede observar patrones de acceso a memoria.
  • argon2id (recomendada): Combinación de las dos anteriores. Resistente tanto a GPU como a ataques de canal lateral.

A diferencia de bcrypt, argon2 permite configurar tres parámetros de coste:

  • timeCost: Número de iteraciones.
  • memoryCost: Memoria RAM requerida (en KiB). Dificulta ataques en hardware paralelo.
  • parallelism: Número de hilos.
Versión nativa de Node.js

crypto.argon2() y crypto.argon2Sync() se añadieron en Node.js 24.7.0 (agosto 2025). Si tienes una versión anterior, debes utilizar librerías externas.

const { argon2, randomBytes } = await import('node:crypto');

const password = 'mi-contraseña';
const salt = randomBytes(32);

const derivedKey = argon2('argon2id', {
message: password,
nonce: salt,
parallelism: 4,
tagLength: 64,
memory: 65536,
passes: 3,
});

const hashedPassword = derivedKey.toString('hex');
console.log(hashedPassword);

La salida sería similar a lo siguiente:

2004e8d30638d9a6d27cf19e1a23ac27765309eb204f04ab1d5ffb07937afced02fa7391c5a124a8d8f51737afc84ff6f780030cda8c9e3e6f1c96a4f490efab

Para hashing de contraseñas, la API nativa trabaja a un nivel más bajo y obliga a gestionar el salt y la serialización de forma manual. Sin embargo, el paquete @node-rs/argon2 sigue siendo la opción más ergonómica porque incluye verify() y el formato PHC estándar listo para guardar en base de datos.

import { hash, verify, Algorithm, Version } from '@node-rs/argon2';

const CONFIG = {
algorithm: Algorithm.Argon2id,
version: Version.V0x13,
memoryCost: 2 ** 16, // 64 MiB
timeCost: 3, // iteraciones
parallelism: 4, // hilos
saltLength: 32, // bytes (generado automáticamente)
secret: Buffer.from(process.env.PEPPER ?? 'clave-dev-no-usar-en-prod'),
};

// ─── Hashear ────
async function hashPassword(plaintext) {
if (!plaintext || plaintext.length < 8) {
throw new Error('La contraseña debe tener al menos 8 caracteres');
}
return await hash(plaintext, CONFIG);
}

// ─── Verificar ────
// verify() extrae el salt y los parámetros del propio hash (formato PHC),
// así que solo necesitas el hash almacenado y la contraseña introducida.
async function verifyPassword(storedHash, plaintext) {
return await verify(storedHash, plaintext, { secret: CONFIG.secret });
}

// ─── Crear hash ────
const password = '__mi-contraseña-segura__';
const storedHash = await hashPassword(password);

// ─── Verificar hash ────
const correctAttempt = await verifyPassword(storedHash, password); // true
const wrongAttempt = await verifyPassword(storedHash, 'contraseña-incorrecta'); // false

El resultado del hash sería algo similar a lo siguiente:

$argon2id$v=19$m=65536,t=3,p=4$wSf+3GrSTJ0QoRcANStWWw$HeMS2/BDRNIKHZwwMVer8DvmgD4B10/KvXovYCNHciM

Esta cadena de texto es la que deberíamos guardar y recuperar de la base de datos.

Cuándo usarlo: En proyectos nuevos donde puedas instalar dependencias nativas. Es la mejor opción disponible actualmente.

scrypt

Diseñado por Colin Percival (2009) con énfasis en el coste de memoria, lo que lo hace especialmente resistente a ataques con hardware especializado (ASICs, FPGAs). Está incluido en la librería estándar de Node.js (crypto), sin necesidad de instalar nada.

import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

const scryptAsync = promisify(scrypt);

export const hashPassword = async (plainPassword) => {
const salt = randomBytes(16).toString('hex');
const derivedKey = await scryptAsync(plainPassword, salt, 64);
return `${salt}:${derivedKey.toString('hex')}`;
};

export const verifyPassword = async (plainPassword, storedHash) => {
const [salt, hash] = storedHash.split(':');
const derivedKey = await scryptAsync(plainPassword, salt, 64);
const storedBuffer = Buffer.from(hash, 'hex');
// timingSafeEqual evita ataques de temporización
return timingSafeEqual(derivedKey, storedBuffer);
};

Cuándo usarlo: Cuando no puedes instalar dependencias externas (entornos muy restringidos) o necesitas una solución solo con la stdlib de Node.js.

PBKDF2

Estándar NIST (PKCS #5), también disponible en la stdlib de Node.js. Aplica una función pseudoaleatoria (normalmente HMAC-SHA256) miles de veces. Menos resistente a ataques de hardware que scrypt o argon2, pero ampliamente auditado y requerido en entornos con cumplimiento normativo (FIPS, etc.).

import { pbkdf2, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

const pbkdf2Async = promisify(pbkdf2);

const ITERATIONS = 310_000; // NIST recomienda ≥310.000 para HMAC-SHA256 (2023)
const KEY_LEN = 32;
const DIGEST = 'sha256';

export const hashPassword = async (plainPassword) => {
const salt = randomBytes(16).toString('hex');
const key = await pbkdf2Async(plainPassword, salt, ITERATIONS, KEY_LEN, DIGEST);
return `${salt}:${ITERATIONS}:${key.toString('hex')}`;
};

export const verifyPassword = async (plainPassword, storedHash) => {
const [salt, iterations, hash] = storedHash.split(':');
const key = await pbkdf2Async(plainPassword, salt, parseInt(iterations), KEY_LEN, DIGEST);
return timingSafeEqual(key, Buffer.from(hash, 'hex'));
};

Cuándo usarlo: En entornos con requisitos de cumplimiento normativo (FIPS 140-2, PCI-DSS) que exigen algoritmos certificados.

Comparativa de algoritmos

AlgoritmoResistencia a GPUEn stdlib Node.jsRecomendación
MD5 / SHA-256❌ Muy bajaNunca para contraseñas.
PBKDF2⚠️ MediaSólo si hay exigencia normativa.
bcrypt✅ AltaAmpliamente soportado.
scrypt✅ AltaBuena opción sin dependencias externas.
argon2id✅ Muy altaMejor opción para nuevos proyectos.
Recomendación
  • Siempre que se pueda, usar argon2id.
  • Si no, alguna de estas opciones:
    • bcrypt con, por lo menos, 12 rounds.
    • scrypt con la stdlib.
  • Nunca usar MD5, SHA-1 o SHA-256 para contraseñas.