flowchart LR
subgraph Reg["Registro"]
P1["contraseña<br/><i>existe 200ms en memoria</i>"] --> H1["bcrypt<br/>+ salt aleatorio<br/>+ costo 10"] --> DB[("users.password_hash<br/><i>$2b$10$N9qo8uLO...</i>")]
end
subgraph Login["Login"]
P2["contraseña escrita"] --> H2["bcrypt.compare<br/>(mismo salt, mismo costo)"] --> Q{"¿coincide<br/>con el hash?"}
Q -->|sí| OK["login"]
Q -->|no| N["401 genérico:<br/>'credenciales inválidas'"]
end
23 14. Necesitamos guardar contraseñas sin traicionar a nadie
Texto plano nunca; cifrado tampoco. ¿Entonces qué?

Taskflow va a tener usuarios — y la primera decisión de seguridad del libro: las contraseñas no se guardan, se hashean. Ni texto plano (una filtración = todas las claves expuestas) ni cifradas (la clave que las descifra también puede filtrarse). Se guarda un hash bcrypt — lento a propósito — y al hacer login se hashea lo que escribió el usuario y se comparan. La BD jamás conoce la contraseña real.
23.1 El problema
users.password TEXT con la contraseña dentro es una bomba de tiempo: las bases de datos se filtran (backups perdidos, inyección SQL, un empleado descontento). Cuando pase — cuando, no si — todo usuario queda desnudo, y como la gente reutiliza contraseñas, tu filtración abre sus cuentas en otros sitios. Tu falla se convierte en la de todos.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
¿Cifrarla entonces? Peor trampa: el cifrado es reversible por diseño — existe una clave que lo deshace, y esa clave vive en tu servidor. Quien robe la BD probablemente robe también la clave. Necesitas algo
El cifrado convierte datos en basura legible solo con la llave — y a diferencia del hash, se puede revertir. HTTPS cifra el cable; las contraseñas NO se cifran, se hashean (no hay por qué revertirlas).
irreversible: una función que transforma micontraseña123 en $2b$10$N9qo8uLO... de tal forma que no hay camino de regreso. Eso es un hash.
Un hash es una función de un solo sentido: contraseña →$2b\(10\)…`. No se puede revertir — por eso las contraseñas se hashean, no se cifran. bcrypt es lento a propósito: fuerza bruta cara para el atacante.
23.2 Cómo lo resuelve un equipo
El flujo universal (idéntico en los tres stacks):
- Registro: el usuario manda su contraseña → el servidor la hashea → guarda el hash. La contraseña en claro existe solo durante ese request.
- Login: el usuario manda su contraseña → el servidor hashea lo recibido → compara con el hash guardado → coinciden = es él.
- La BD almacena
password_hash— y el contrato de salida del cap. 5 se asegura de que nunca salga en un JSON.
¿Y qué impide que el atacante también hashee un diccionario de contraseñas comunes y compare? El salt y el costo. bcrypt agrega un salt aleatorio por usuario (dos usuarios con la misma clave tienen hashes distintos) y es deliberadamente lento (~100ms por intento): para tu login es imperceptible; para quien prueba mil millones de combinaciones es una eternidad. MD5 y SHA256 son hashes rápidos — rápidos para ti y para el atacante. Por eso no sirven para contraseñas.
Un diccionario (dict en Python, map en Go, objeto en JS) guarda parejas clave→valor: {"ana": 30}. Es la estructura que más aparece en JSON — un objeto JS es un diccionario.
La autenticación responde “¿quién eres?” — login, contraseña, token. Se confunde con autorización (“¿qué puedes hacer?”), que es la pregunta siguiente. Primero te identificas, luego te dejan o no pasar.
23.3 Conceptos nuevos
- U Hash ≠ cifrado: irreversible vs reversible — la pregunta de entrevista
- U bcrypt: salt incorporado, factor de costo, “lento” como característica de seguridad
- TS
bcryptnpm · Pybcryptpip · Gogolang.org/x/crypto/bcrypt— librerías distintas, algoritmo idéntico - U Qué se guarda (
password_hash) y qué jamás aparece en el contrato de salida
23.4 La explicación visual
El hash contiene todo empaquetado: $2b$10$ dice “bcrypt, costo 10” y el salt va embebido — una sola columna basta.
23.5 Implementación
POST /auth/register real: validar (cap. 4) → hashear → persistir (cap. 10). La línea importante en cada stack es una sola.
import bcrypt from "bcrypt";
// register — hash before persisting, the plaintext dies here
export async function registerUser(email: string, password: string) {
const passwordHash = await bcrypt.hash(password, 10); // cost factor 10
const { rows } = await pool.query(
`INSERT INTO users (email, password_hash) VALUES ($1, $2)
RETURNING public_id, email`, // never RETURNING password_hash
[email, passwordHash],
);
return rows[0];
}
// login — compare what they typed against the stored hash
export async function verifyUser(email: string, password: string) {
const { rows } = await pool.query(
"SELECT id, password_hash FROM users WHERE email = $1", [email],
);
const user = rows[0];
if (!user) return null; // no user → null
const ok = await bcrypt.compare(password, user.password_hash);
return ok ? user : null; // same answer either way
}import bcrypt
def register_user(email: str, password: str) -> dict:
password_hash = bcrypt.hashpw(
password.encode(), bcrypt.gensalt(rounds=10) # salt + cost built in
).decode()
row = conn.execute(
"""INSERT INTO users (email, password_hash) VALUES (%s, %s)
RETURNING public_id, email""", # never the hash
(email, password_hash),
).fetchone()
return row
def verify_user(email: str, password: str):
user = conn.execute(
"SELECT id, password_hash FROM users WHERE email = %s", (email,),
).fetchone()
if not user:
return None
ok = bcrypt.checkpw(password.encode(), user["password_hash"].encode())
return user if ok else None # same answer either wayfunc registerUser(ctx context.Context, email, password string) (User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) // cost 10
if err != nil {
return User{}, err
}
var u User
err = pool.QueryRow(ctx,
`INSERT INTO users (email, password_hash) VALUES ($1, $2)
RETURNING public_id, email`, email, string(hash), // never the hash
).Scan(&u.PublicID, &u.Email)
return u, err
}
func verifyUser(ctx context.Context, email, password string) (*User, error) {
var u User
err := pool.QueryRow(ctx,
"SELECT id, password_hash FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.PasswordHash)
if err != nil {
return nil, nil // no user → nil
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
return nil, nil // same answer either way
}
return &u, nil
}Tres detalles que valen oro:
- El handler solo ve éxito/fracaso.
verifyUserdevuelve el usuario onull— nunca “usuario no existe” vs “contraseña mala” por separado. Esa distinción le dice al atacante qué emails están registrados (enumeración de usuarios, cap. 17). RETURNING/SELECTjamás incluyepassword_hashhacia afuera. Se lee para comparar y muere en el servicio — elUserPublicdel contrato no tiene esa columna.- Costo 10 hoy. El factor es configurable: súbelo cuando el hardware lo tolere (cada +1 duplica el trabajo). Se cronometra en el mini reto.
null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
Implementa registro y verificación de contraseña en mi API
[Express / FastAPI / Go]. Requisitos: bcrypt con costo 10 (hashpw/
GenerateFromPassword, NO sha256 ni MD5), la contraseña en claro nunca
se guarda ni se loguea, password_hash nunca sale en ninguna respuesta
JSON, el login devuelve el mismo error genérico para "usuario no existe"
y "contraseña incorrecta" (sin enumeración de usuarios), y las queries
van parametrizadas. Explícame por qué bcrypt lento es mejor que un hash
rápido para este caso.
23.6 ¿Por qué bcrypt y no otra cosa?
Lo único que necesitas llevarte: guardar contraseñas = guardar verificadores, no secretos. El hash bcrypt es un verificador: sirve para comprobar, no para recuperar. Tu base de datos puede publicarse entera mañana y las contraseñas siguen protegidas por el costo de calcularlas una a una.
| Algoritmo | Qué es | Estado |
|---|---|---|
| bcrypt | El clásico probado (1999), salt+costo | Default sano — el del libro |
| argon2 | El moderno: también resiste GPU (memoria dura) | Recomendado OWASP; librerías menos universales |
| scrypt | Similar a argon2, memoria dura | Sólido, menos común |
| PBKDF2 | Iteraciones de hash estándar NIST | Aceptable; para compliance |
| MD5/SHA-256 solo | Hash rápido sin salt | Prohibido para contraseñas — GPUs los muelen |
Si tu stack tiene argon2 maduro, es la elección 2024+. bcrypt sigue siendo defendible y omnipresente — lo que NO se debate es “hash lento con salt” vs cualquier otra cosa.
23.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
| Contraseña en claro en la tabla | “Es la forma obvia” | password_hash bcrypt — la clave nunca persiste |
sha256(password) |
“Es un hash” — sí, rápido: 10^9 intentos/segundo en GPU | bcrypt/argon2: lento es el punto |
| Mismo salt para todos / sin salt | Rainbow tables lo resuelven en una query | bcrypt genera salt por usuario automáticamente |
| Error distinto para email vs password | “Email no registrado” confirma qué emails existen | Mismo “credenciales inválidas” para ambos casos |
password_hash en el JSON del usuario |
El SELECT * del cap. 9 | Columnas explícitas + contrato de salida (cap. 5) |
| Loguear la contraseña “para debug” | El log vive para siempre y lo lee medio equipo | La contraseña no toca console.log jamás |
23.8 Buenas prácticas
- El hash se calcula en el servicio, no en el handler — el handler traduce HTTP; la regla “nunca persistir en claro” es de negocio.
- Compara, no decidas:
bcrypt.comparemaneja timing seguro — no compares hashes con===(timing attacks son reales en teoría; usarcomparecuesta lo mismo). - Costo ajustable por config, no quemado en código — subirlo en prod no debería requerir refactor.
- Límite de longitud razonable (ej. 72 bytes es el máximo real de bcrypt): valida
password.length <= 72o trunca conscientemente.
EXTRA Los roadmaps piden “entender la seguridad web” — el mapa completo, ubicado:
- MD5 — roto; nunca para contraseñas (solo checksums)
- Familia SHA — integridad y firmas; demasiado rápida para passwords
- scrypt — alternativa seria a bcrypt, también lenta a propósito
- bcrypt — la de este capítulo: lenta + salt incorporado
- HTTPS / SSL / TLS — el canal cifrado (cap-33 lo sirve)
- CORS — política de orígenes del navegador (cap-18)
Regla que no caduca: contraseñas → bcrypt/scrypt/argon2; integridad → SHA/HMAC; MD5 → solo para verificar que un archivo no se corrompió.
23.9 Ejercicio
- Explica en una frase por qué cifrar contraseñas es peor que hashearlas, aunque el cifrado suene más seguro.
- Dos usuarios eligen la misma contraseña
hunter2. ¿Qué ven en la columnapassword_hash? ¿Por qué? - Escribe el
verifyde tu stack que devuelva la misma respuesta para usuario inexistente y contraseña mala.
El cifrado es reversible: existe una clave que deshace todo — y quien roba tu BD probablemente roba también esa clave del servidor. El hash no tiene clave que robar: la única forma de “romperlo” es adivinar la contraseña original, intento a intento.
Dos hashes completamente distintos — bcrypt genera un salt aleatorio por cada
hash/hashpw/GenerateFromPassword. Dos claves iguales producen hashes irreconocibles entre sí, así que la filtración no revela quiénes comparten contraseña.TypeScript:
export async function verifyUser(email: string, password: string) { const { rows } = await pool.query( "SELECT id, password_hash FROM users WHERE email = $1", [email]); const user = rows[0]; if (!user) return null; const ok = await bcrypt.compare(password, user.password_hash); return ok ? user : null; // both failures return null }El handler responde
401 {error: "invalid credentials"}en ambos casos — el atacante no puede distinguirlos.
23.10 Mini reto
Cronometrar bcrypt con distintos costos y justificar la elección.
// time each cost factor — run once, pick deliberately
for (const cost of [8, 10, 12, 14]) {
const t = Date.now();
await bcrypt.hash("benchmark", cost);
console.log(cost, Date.now() - t, "ms");
}
// typical: 8≈25ms 10≈80ms 12≈320ms 14≈1.3sLa justificación: busca el costo donde tu login siga siendo instantáneo para un humano (~100-300ms) pero cada intento del atacante cueste máximo. Costo 10-12 es el punto dulce actual. Detalle importante: el costo va embebido en el hash ($2b$10$...), así que hashes viejos siguen verificando aunque subas el factor — y puedes rehashear en el próximo login exitoso del usuario.
23.11 Vocabulario técnico del capítulo
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.
Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.
Una imagen es la plantilla inmutable de la que nacen contenedores: la foto del disco + cómo arrancar. Se construye en capas (Dockerfile), se versiona con tags, se publica en un registry.
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.
Un pool es el staff de conexiones a la BD: abrir una conexión por request es caro, así que el pool mantiene ~10–20 abiertas y las presta. El request la usa, la devuelve, y la siguiente la reutiliza.
La autorización responde “¿qué puedes hacer?”: eres usuario válido (autenticado), pero ¿puedes borrar esta tarea? Se decide por rol o por ownership — y se verifica en cada request, no se recuerda.
Un log es el diario del programa: qué pasó, cuándo, con qué request. Estructurado = en JSON con campos (request_id, user_id), para filtrar por máquina y no con los ojos.
Un secreto es un dato que no puede publicarse: contraseñas de BD, llaves de API, el secreto que firma los JWT. Viven en .env (fuera de git) o en un secret manager — nunca en el código ni en el repo.