Laboratorio cerrado · nada de esto sale de tu navegador
Escape room: cinco fallos de autenticación
Un edificio digital abandonado con cinco salas, y en cada una un mecanismo de autenticación que sigue funcionando mal desde hace años. Entras como Modo Dios: puedes ver el interior de los sistemas, y eso incluye el interior de los tokens.
Los JWT se firman y se verifican de verdad aquí dentro —hay una implementación de SHA-256 y HMAC-SHA256 en la propia página—, y los «servidores» son funciones JavaScript. No hay red, ni sistemas reales, ni nada que salga de la pestaña. Las ayudas y el tiempo restan puntos: lo que deduzcas tú, no.
Continúa las herramientas de Recursos: allí están la calculadora de hash, el HMAC y el inspector de JWT con los que jugar por tu cuenta.
EL EDIFICIO SIN PORTEROS
Informe de entrada
Qué vas a aprender
- Sala 1 · Un JWT va firmado, no cifrado.
- Sala 2 · Por qué
alg: nonees una puerta abierta. - Sala 3 · Entropía: qué separa un token adivinable de uno imposible.
- Sala 4 · La ruta que se olvidó de pasar por el guardián.
- Sala 5 ·
decode()lee.verify()protege.
Reglas del laboratorio
Todo lo que ves aquí está simulado dentro de esta página. Los "servidores" son funciones JavaScript. Los tokens se firman en tu navegador con una implementación real de HMAC-SHA256 incluida en el archivo. No hay red, no hay nada externo, no hay ningún sistema real implicado.
Pulsa 👁 RAYOS X en cualquier momento: es tu poder de Modo Dios. Revela el código y las tripas de cada sala. Pero todo poder se paga.
El Cofre Transparente
Token hallado en el cofre
⚠ Sótano · decodificador de emergencia −75 PUNTOS
El edificio conserva un decodificador Base64url automático. Funciona, y te dará la respuesta en un clic. Pero un Modo Dios que necesita herramientas no es gran cosa: deshacer Base64 por tus propios medios no cuesta ni un punto y es exactamente la lección de esta sala.
Visión de rayos X
// Anatomía de un JWT base64url(cabecera) . base64url(carga) . base64url(HMAC-SHA256(cabecera.carga, clave)) └──── público ────┘ └──── público ───┘ └──────── prueba de integridad ────────┘ // Lo que la firma SÍ garantiza: ✔ nadie ha alterado la carga sin conocer la clave // Lo que la firma NO hace: ✘ NO oculta la carga — cualquiera la lee con un atob() ✘ NO impide copiar el token entero y reutilizarlo // Clave del laboratorio (sólo existe en esta página): LAB_SECRET =
Reto · abrir la puerta
Dentro de la carga hay un campo llamado clave_boveda. Escribe su valor exacto.
Firmado ≠ cifrado. Un JWT es un sobre transparente con un sello de lacre. El sello impide falsificarlo; el sobre no impide leerlo. Cualquiera que intercepte, copie o simplemente reciba el token ve el contenido completo: identificadores, roles, correos, permisos... y cualquier "secreto" que alguien haya metido ahí pensando que Base64 lo protegía.
Base64url es una codificación, no un cifrado: convierte bytes en texto imprimible. Es reversible por definición y sin ninguna clave.
Ficha de defensa · qué hacer en tu código
- Nunca metas datos sensibles en el payload: contraseñas, claves de API, números de tarjeta, datos personales innecesarios.
- Guarda en el token lo mínimo para identificar:
sub,role,exp. El resto se consulta en la base de datos. - Si de verdad necesitas confidencialidad en el token, no es un JWS: necesitas JWE (JSON Web Encryption) o un token opaco por referencia.
- Da por hecho que el usuario leerá todo lo que pongas dentro. Porque lo hará.
// ✘ mal: el usuario puede leerlo todo sign({ sub: id, plan_interno: "trial_hack", email: user.email }, key) // ✔ bien: mínimo imprescindible + caducidad corta sign({ sub: id, role: user.role }, key, { expiresIn: '15m' })
La Firma Rota
Tu pase actual (legítimo, firmado)
Visión de rayos X
// SERVIDOR LEGADO — implementado de verdad más abajo en este archivo function servidorLegado(token) { const [h, p, s] = token.split('.'); const cabecera = JSON.parse(b64urlDecode(h)); if (cabecera.alg === 'none') { // ⚠ LA PUERTA ABIERTA return JSON.parse(b64urlDecode(p)); // acepta sin comprobar NADA } if (!firmaValida(h, p, s, CLAVE)) throw new Error('firma inválida'); return JSON.parse(b64urlDecode(p)); } // El atacante no rompe la criptografía. // Le dice al servidor: "hoy no usamos criptografía". // Y el servidor le cree, porque el ATACANTE elige el algoritmo.
Forja de tokens
Edita las piezas y ensambla. Vaciar la firma deja el token terminado en un punto: cabecera.carga.
El atacante no debe poder elegir el algoritmo. El fallo de alg: none no está en la criptografía, está en la confianza: el servidor lee la cabecera —que viene del cliente— y obedece lo que ésta le dice sobre cómo validarse a sí misma. Es preguntarle al sospechoso si hay que registrarlo.
La misma familia de fallos incluye la confusión de algoritmos: un servidor que acepta HS256 cuando esperaba RS256 puede ser engañado usando la clave pública (que es pública) como si fuera el secreto HMAC.
Ficha de defensa · qué hacer en tu código
- Fija el algoritmo en el servidor y rechaza cualquier otro, siempre con lista blanca.
- Rechaza explícitamente
noney cualquier token sin firma, aunque la librería diga que lo soporta. - No deduzcas la clave ni el algoritmo de la cabecera: decídelos por configuración.
- Si usas
kidpara elegir clave, valídalo contra un conjunto cerrado (nunca como ruta de fichero ni consulta SQL).
// ✘ mal: la librería usa lo que diga la cabecera jwt.verify(token, clave) // ✔ bien: lista blanca explícita de algoritmos jwt.verify(token, clave, { algorithms: ['HS256'], issuer: 'mi-api', audience: 'mi-app' })
La Llave de Guardarropa
⚠ Armario A · contador
Últimas llaves entregadas (en orden de emisión):
✔ Armario B · 256 bits aleatorios
Últimas llaves entregadas (generadas con el CSPRNG del navegador):
Visión de rayos X
// ARMARIO A — predecible let contador = 4271; function nuevaSesionDebil() { contador += 3; // paso fijo return 'TKT-' + String(contador).padStart(7, '0'); } // Espacio de búsqueda real: ~1 intento. Y con Date.now() o Math.random(), igual de malo. // ARMARIO B — impredecible function nuevaSesionFuerte() { const bytes = crypto.getRandomValues(new Uint8Array(32)); // 256 bits return [...bytes].map(b => b.toString(16).padStart(2,'0')).join(''); } // Espacio de búsqueda: 2^256 ≈ 1,16 × 10^77 valores.
Reto · dos condiciones
◻ Predecir la siguiente llave del armario A · ◻ Ejecutar el ataque contra el armario B y ver el resultado
Un token opaco sólo vale lo que vale su entropía. Si el identificador de sesión se puede predecir, no hace falta robarlo: se calcula. Contadores, marcas de tiempo, hashes de correo, UUID v1 o Math.random() producen valores que parecen aleatorios pero cuyo espacio de búsqueda real es diminuto.
La diferencia no es de grado, es de categoría: adivinar la llave del armario A cuesta un intento; la del B, más intentos que átomos hay en la parte visible del universo.
Ficha de defensa · qué hacer en tu código
- Genera identificadores de sesión y tokens con un CSPRNG, nunca con
Math.random(). - Mínimo 128 bits de entropía; 256 bits es el estándar cómodo hoy.
- No derives el token de datos del usuario (id, email, hora de login): eso no es entropía, es disfraz.
- Añade caducidad, rotación al iniciar sesión y revocación real en el servidor.
- Compara tokens con una función de tiempo constante para no filtrar información por el tiempo de respuesta.
// ✘ mal const sid = Date.now() + '-' + Math.random().toString(36).slice(2) // ✔ bien (Node.js) import { randomBytes, timingSafeEqual } from 'node:crypto' const sid = randomBytes(32).toString('base64url') // 256 bits // ✔ bien (navegador / Web Crypto) crypto.getRandomValues(new Uint8Array(32))
La Puerta sin Guardián
Mapa de rutas del edificio
Pulsa una ruta para llamar a esa puerta. (Con RAYOS X verás la cadena de middlewares de cada una.)
Visión de rayos X
// Así se escribió el router. Busca la línea que falta. router.get ('/api/perfil', requireAuth, perfilHandler); router.post('/api/pagos', requireAuth, requireRole('user'), pagosHandler); router.get ('/api/informes', requireAuth, informesHandler); router.post('/api/exportar', requireAuth, requireRole('admin'), exportarHandler); router.get ('/healthz', healthHandler); // público a propósito, no expone datos router.get ('/debug/config', configHandler); // ⚠ ¿y el requireAuth? // El problema no es que falte una comprobación: // es que el modelo por defecto sea PERMITIR y haya que acordarse de prohibir.
Reto · la frase de servicio
La puerta olvidada filtra la configuración interna. Dentro hay un campo FRASE_DE_SERVICIO. Escríbelo aquí.
La autenticación perfecta en 19 rutas no sirve de nada si la número 20 no la tiene. Este es el fallo más común y más aburrido de todos: nadie rompió nada, simplemente alguien olvidó una línea. Rutas de depuración, endpoints internos, versiones antiguas de la API (/v1/ olvidada al publicar /v2/), webhooks, métricas, paneles de administración "que nadie conoce".
La causa raíz es el modelo por defecto: si tu router permite por defecto y la protección es opt-in, cada ruta nueva es una oportunidad de olvido. Invierte la carga.
Ficha de defensa · qué hacer en tu código
- Denegar por defecto. Aplica el middleware de autenticación globalmente y declara una lista blanca explícita de rutas públicas.
- Autoriza en la capa que no se puede saltar (servicio o acceso a datos), no sólo en el router.
- Prohíbe en producción los endpoints de depuración: que ni siquiera se registren si
NODE_ENV === 'production'. - Añade un test que recorra el árbol de rutas y falle si alguna no está ni protegida ni en la lista blanca.
- Recuerda: una ruta no deja de existir por no estar documentada. La oscuridad no es control de acceso.
// ✔ denegar por defecto const PUBLICAS = new Set(['/healthz', '/login']) app.use((req, res, next) => PUBLICAS.has(req.path) ? next() : requireAuth(req, res, next)) // ✔ que la ruta de depuración ni exista fuera de desarrollo if (process.env.NODE_ENV !== 'production') app.get('/debug/config', requireAuth, configHandler)
La Sala del Espejo
Token manipulado
Alguien tomó un token legítimo de auditor, cambió la carga a root y dejó la firma original intacta (no tenía la clave para recalcularla).
✘ SERVIDOR A · usa decode()
✔ SERVIDOR B · usa verify()
Visión de rayos X
// SERVIDOR A app.use((req, res, next) => { const carga = jwt.decode(req.headers.authorization); // sólo LEE. No comprueba nada. req.usuario = carga; // confía en texto del cliente next(); }); // SERVIDOR B app.use((req, res, next) => { try { const carga = jwt.verify(req.headers.authorization, CLAVE, { algorithms: ['HS256'] }); req.usuario = carga; // sólo si el MAC cuadra next(); } catch { res.status(401).json({ error: 'token inválido' }); } }); // decode() existe para depurar y para leer un token del que YA eres emisor. // Usarlo para decidir permisos equivale a no tener autenticación.
Reto · el informe final
Lanza el token contra los dos servidores y después responde: ¿qué conclusión es la correcta?
La seguridad vive en la verificación del servidor. Un token es sólo texto que llega desde fuera. Lo que lo convierte en una credencial no es su formato ni su aspecto: es que un servidor con la clave haya recalculado el MAC —o comprobado la firma pública— y haya obtenido el mismo resultado. Sin ese paso, un JWT es un formulario que el usuario rellena a su gusto.
Por eso no importa cuántas comprobaciones haga el cliente: el cliente está en manos del atacante. El único punto de la cadena que decide es el servidor.
Ficha de defensa · qué hacer en tu código
decode()jamás para tomar decisiones de acceso. Sóloverify(), y sólo en el servidor.- Verifica también los claims:
exp,nbf,iss,aud. Una firma válida de otro emisor sigue siendo un token ajeno. - Ninguna validación en el cliente cuenta como control de seguridad: es sólo experiencia de usuario.
- Compara firmas en tiempo constante y guarda la clave fuera del código (variable de entorno o gestor de secretos).
// ✘ mal: el payload es texto del cliente const u = jwt.decode(token); if (u.role === 'admin') /* ... */ // ✔ bien: verificación completa const u = jwt.verify(token, CLAVE, { algorithms: ['HS256'], issuer: 'mi-api', audience: 'mi-app', clockTolerance: 5 });
SISTEMA AUDITADO
Informe de auditoría
Lo aprendido
Firmado ≠ cifrado
Un JWT es un sobre transparente con sello. Todo el mundo lee su contenido. Nunca metas secretos dentro; si necesitas confidencialidad, usa JWE o tokens por referencia.
El algoritmo lo decides tú, no el token
alg: none y la confusión de algoritmos existen porque el servidor obedece a la cabecera. Fija una lista blanca de algoritmos en la verificación.
Entropía criptográfica
Un token opaco vale lo que vale su aleatoriedad. CSPRNG, 128 bits como mínimo, 256 recomendado. Nada de contadores, timestamps ni Math.random().
Todas las rutas, sin excepción
Denegar por defecto y lista blanca de rutas públicas. Autoriza en la capa que no se puede saltar. Los endpoints de depuración no deberían existir en producción.
verify(), nunca decode()
La firma es la única prueba de integridad. Verifica también exp, iss y aud. Lo que valide el cliente no cuenta como seguridad.
Todo dato que llega del cliente es una afirmación, no un hecho
Un token, una cookie, una cabecera o un campo oculto sólo se convierten en verdad cuando el servidor los verifica con algo que el cliente no controla.
Checklist para llevarte
- ☐
verify()conalgorithmsexplícito en cada petición autenticada. - ☐ Payload mínimo y caducidad corta; refresco rotatorio y revocable.
- ☐ Secretos en variables de entorno o gestor de secretos, nunca en el repositorio.
- ☐ Identificadores de sesión de ≥128 bits desde un CSPRNG.
- ☐ Middleware de auth global + lista blanca de rutas públicas, con test automático.
- ☐ Autorización por rol comprobada en el servidor, además de la autenticación.
- ☐ Endpoints de depuración deshabilitados en producción.
- ☐ Comparaciones de tokens y firmas en tiempo constante.