🔐Ciberseguridad

Cómo almacenar contraseñas de forma segura con Argon2id en Node.js

Felipe••10 min de lectura
Aprende por qué SHA-256 no basta para guardar contraseñas, cómo funcionan salt y pepper, y cómo usar Argon2id en Node.js con las recomendaciones de OWASP.

Cuando alguien inicia sesión, tu aplicación necesita comprobar una contraseña. No necesita conservarla en texto claro ni poder recuperarla después. Esta diferencia determina qué guardar en la base de datos y cómo verificarlo.

En esta guía construiremos un ejemplo local con Argon2id y Node.js. Antes veremos qué problema resuelve, qué aportan el salt y el pepper, y cómo elegir un costo razonable sin confundir una recomendación mínima con una configuración definitiva.

El escenario que debemos contemplar: una copia de la base de datos

Imagina que alguien obtiene una copia de la tabla de usuarios. Puede probar contraseñas en su propio equipo y comparar sus resultados con los registros robados. Eso es un ataque offline: no pasa por tu formulario, por lo que el límite de intentos del servidor no frena esas pruebas. NIST SP 800-63B-4, apéndice A.

No necesita «descifrar» un hash. Puede acertar una contraseña probable, como una palabra frecuente, y comprobarla. El diseño del almacenamiento busca encarecer cada intento; no convierte una contraseña predecible en un secreto imposible de adivinar.

Un hash general y una función para contraseñas tienen objetivos distintos

SHA-256 es una función criptográfica de propósito general definida en FIPS 180-4 de NIST. Produce un resumen de 256 bits a partir de una entrada. Que no sea apropiada por sí sola para guardar contraseñas no significa que SHA-256 esté criptográficamente roto ni que sus usos en otros protocolos sean inseguros.

El problema aquí es su rapidez: calcular muchos candidatos resulta barato. OWASP desaconseja los hashes rápidos, incluido SHA-256, para este almacenamiento. Añadir un salt a SHA-256 no introduce el costo de una función especializada. OWASP Password Storage Cheat Sheet.

KDF significa Key Derivation Function: en español, función de derivación de claves. Aquí, «derivar» significa calcular un nuevo valor a partir de un dato de entrada. Según el uso, ese valor puede servir como clave para una operación criptográfica o como resultado que permita comprobar una contraseña sin guardarla en texto claro.

En este artículo nos interesan las KDF basadas en contraseñas, también llamadas password KDF. Combinan una contraseña con un salt y un costo configurable para que cada intento de adivinarla requiera trabajo. PBKDF2 es un ejemplo descrito en RFC 8018. No todas las KDF tienen este objetivo: algunas parten de secretos que ya son fuertes y no están diseñadas para encarecer las pruebas de contraseñas.

Cuando utilizamos el resultado para verificar una contraseña, hablamos de hashing de contraseñas. Ese será nuestro uso de Argon2id: guardar un resultado de verificación y comprobar después si la contraseña recibida corresponde a él, sin recuperar la contraseña original.

MecanismoPropósito en este contextoCosto configurable
SHA-256 aplicado una vezObtener un resumen generalNo incorpora un factor de trabajo para contraseñas
PBKDF2Derivar material a partir de una contraseñaIteraciones
bcryptVerificar contraseñas mediante un hash especializadoFactor de trabajo exponencial
Argon2idHashing de contraseñas y derivación con uso intensivo de memoriaMemoria, pasadas y paralelismo

La tabla resume los mecanismos descritos en FIPS 180-4, RFC 8018, la documentación de bcrypt y RFC 9106. Para repasar la diferencia con cifrado y codificación, puedes consultar la guía de hashing, cifrado y Base64.

Salt: una variación pública para cada contraseña

Un salt es un valor aleatorio que se incorpora al cálculo. No es una segunda contraseña: se guarda junto al resultado. Su función es impedir que una misma tabla de resultados precalculados sirva para todos los registros. Dos usuarios con la misma contraseña y salts distintos tendrán normalmente resultados distintos. RFC 8018, sección 4.1.

Al verificar, se reutiliza el salt guardado; no se genera uno nuevo para comparar dos cadenas a mano. El salt tampoco impide probar candidatos contra un registro individual. En el laboratorio dejaremos su generación a la biblioteca.

Pepper: un secreto adicional con responsabilidades propias

Un pepper añade un secreto compartido, separado de la base de datos. OWASP lo presenta como defensa adicional y recomienda custodiarlo en un gestor de secretos o en un HSM (Hardware Security Module, o módulo de seguridad de hardware): un dispositivo especializado en proteger claves criptográficas y realizar operaciones con ellas sin exponerlas fuera del dispositivo. El pepper no sustituye al salt ni al algoritmo. Su rotación tras una filtración puede requerir restablecer contraseñas. OWASP: peppering.

NIST también recomienda una operación adicional con clave secreta y exige almacenarla separadamente cuando se utiliza, dentro del alcance de su guía. NIST SP 800-63B-4, sección 3.1.1.2.

La consecuencia práctica es importante: si un atacante obtiene tanto la base de datos como ese secreto, desaparece esa barrera adicional. Y si tú lo pierdes, puedes perder la capacidad de verificar los registros protegidos con él. Este laboratorio no incorpora pepper: introducirlo exige decidir cómo custodiarlo, recuperarlo y rotarlo, no pegar una constante secreta en el código.

Argon2id y sus factores de costo

Argon2id combina propiedades de las variantes Argon2i y Argon2d. Su diseño exige memoria además de cómputo, lo que busca encarecer la evaluación masiva de candidatos. Sus parámetros principales son:

  • Memoria (m): espacio de trabajo, expresado en KiB.
  • Pasadas (t): recorridos sobre esa memoria.
  • Paralelismo (p): cantidad de vías de cálculo coordinadas.

Son parámetros diferentes, no tres formas intercambiables de decir «más seguridad». RFC 9106, secciones 1 y 3.1.

La recomendación mínima actual de OWASP es Argon2id con 19 MiB, 2 pasadas y paralelismo 1. Para sistemas heredados con bcrypt, indica un costo mínimo de 10. Son recomendaciones de implementación, no una certificación ni una obligación legal universal. OWASP Password Storage Cheat Sheet.

RFC 9106 es un documento informativo del IRTF; propone, entre sus perfiles recomendados, Argon2id con 64 MiB, 3 pasadas y 4 vías para entornos con memoria limitada. No describe ese perfil como el mínimo de OWASP. RFC 9106, sección 4.

Mi recomendación práctica es empezar con un perfil documentado y medirlo en la infraestructura real: duración, memoria y accesos simultáneos. Un login aislado puede funcionar bien mientras cien verificaciones concurrentes saturan el servicio. Aumentar el costo sin medir capacidad también puede perjudicar su disponibilidad. El laboratorio muestra el mínimo de OWASP para hacer explícitos los parámetros; no pretende elegir el costo final de tu aplicación.

bcrypt: útil en sistemas existentes, con un límite que debes conocer

bcrypt tiene un factor de trabajo exponencial: incrementar el costo de 10 a 11 aproximadamente duplica el trabajo. No equivale a añadir una sola iteración. Su implementación en Node.js utiliza únicamente los primeros 72 bytes, no 72 caracteres; una cadena UTF-8 con emojis puede superar ese tamaño con menos caracteres. Documentación de node.bcrypt.js.

Si mantienes bcrypt, controla ese límite explícitamente y no trunques contraseñas de forma silenciosa. Para una implementación nueva, este artículo elige Argon2id siguiendo OWASP. Tampoco propone un prehash casero con SHA-256 para esquivar el límite de bcrypt.

Laboratorio: guardar y verificar con Argon2id en Node.js

Necesitas Node.js 22 o superior y pnpm. Usa un directorio independiente y únicamente los datos ficticios del ejemplo. El paquete argon2 proporciona operaciones asíncronas de hashing y verificación.

mkdir laboratorio-contrasenas
cd laboratorio-contrasenas

pnpm init
pnpm add --save-exact [email protected]

Si pnpm indica que bloqueó los scripts de instalación, ejecuta pnpm approve-builds y autoriza únicamente argon2 para este laboratorio. La biblioteca incluye binarios para plataformas compatibles; consulta sus requisitos de compilación si tu entorno necesita construir el módulo nativo.

Guarda lo siguiente en passwords.mjs:

import * as argon2 from 'argon2';

const opciones = {
  type: argon2.argon2id,
  memoryCost: 19456, // KiB: 19 MiB
  timeCost: 2,
  parallelism: 1,
};

// Datos ficticios, solo para este laboratorio local.
const password = 'Una frase ficticia para este laboratorio';

// Registro: guarda esta cadena completa en la base de datos.
const passwordHash = await argon2.hash(password, opciones);

// Acceso: utiliza el hash almacenado y la contraseña recibida.
const accesoValido = await argon2.verify(passwordHash, password);
const accesoInvalido = await argon2.verify(passwordHash, 'Otra frase diferente');

// El salt automático cambia el resultado incluso con la misma contraseña.
const otroHash = await argon2.hash(password, opciones);

console.log('Contraseña correcta:', accesoValido);
console.log('Contraseña incorrecta:', accesoInvalido);
console.log('Hashes diferentes:', passwordHash !== otroHash);

Ejecútalo:

node passwords.mjs

La ejecución de este ejemplo produjo:

Contraseña correcta: true
Contraseña incorrecta: false
Hashes diferentes: true

passwordHash contiene la variante, versión, parámetros, salt y resultado en una cadena codificada. Guarda la cadena completa; no necesitas una columna separada para el salt ni guardar la contraseña. verify obtiene los parámetros del registro y comprueba el candidato. No compara un hash nuevo con otro salt. Las opciones y sus unidades están documentadas en node-argon2: Options.

El ejemplo mantiene el registro en memoria para centrarse en estas dos operaciones. No imprime contraseñas ni hashes. Una contraseña incorrecta devuelve false; un error interno o un registro malformado puede lanzar una excepción. En una aplicación, maneja ese fallo sin conceder acceso y sin registrar secretos. API de node-argon2.

Esta prueba comprueba el comportamiento del ejemplo, no es un benchmark ni una auditoría del sistema de autenticación.

Pasar del ejemplo a una aplicación

Como decisiones de integración, separaría el trabajo así:

  1. Registro: validar la entrada, calcular el hash y persistirlo en una columna de texto que no lo trunque.
  2. Acceso: recuperar el registro del usuario y verificar la contraseña recibida. Una excepción no debe abrir una sesión.
  3. Actualización: después de una verificación válida, recalcular con la contraseña recibida si corresponde cambiar parámetros o migrar desde bcrypt. No usar el hash antiguo como si fuera la contraseña original.
  4. Operación: medir concurrencia y revisar los parámetros periódicamente; mantener las contraseñas y los registros de verificación fuera de logs y respuestas de la API.

Estas son pautas de integración para el ejemplo, no un flujo completo de autenticación. El almacenamiento tampoco reemplaza los controles de acceso, el transporte protegido o los límites de intentos online. NIST trata esos controles por separado del almacenamiento resistente a ataques offline. NIST SP 800-63B-4.

Una decisión de almacenamiento que puedas mantener

Para esta aplicación de ejemplo elegiría Argon2id, dejaría el salt a la biblioteca y conservaría la cadena completa para verificarla. Después mediría el costo en el servicio real y definiría cómo actualizar los registros. Incorporaría un pepper solo con un plan operativo para ese secreto.

Lo que debes poder explicar no es únicamente qué algoritmo elegiste: también cuánto trabajo exige cada intento, qué almacenas, cómo verificas y cómo cambiarás esa decisión cuando el sistema evolucione.

Referencias

#almacenar contraseñas#Argon2id#Node.js#hashing de contraseñas#salt#pepper#bcrypt#OWASP

Artículos relacionados