Skip to content
This page has been auto-translated and may contain errors.View in English

Tokens portadores opacos

Una tarjeta de acceso te deja entrar a un edificio de oficinas. No tiene nombre, no tiene foto, y no tiene nada legible adentro. La acercas, un lector la verifica contra una lista, y la puerta se abre o no se abre.

Un token portador opaco es esa tarjeta. Una cadena aleatoria e imposible de adivinar, sin nada adentro que se pueda leer.

"Sin estado" no significa que el servidor olvide todo

La palabra confunde a la gente, así que vale la pena aclararla primero.

Que algo sea "sin estado" significa que el servidor no mantiene ninguna sesión: ningún objeto que rastree a un usuario entre solicitudes. El servidor sigue teniendo una base de datos, sigue teniendo usuarios, sigue almacenando cosas. Lo que deja de mantener es un registro de que esta persona está conectada en este momento.

La solicitud tiene que traer todo lo que el servidor necesita para determinar quién la está haciendo.

El servidor deja de recordarte a ti; el cliente empieza a demostrar quién es cada vez.

Dos palabras explican el nombre. Opaco significa que el servidor no puede leer nada dentro del token, porque no hay nada ahí adentro. No está codificado ni estructurado, es una cadena aleatoria. Portador significa que quien lo tiene en su poder, lo usa.

JunoSin estado no significa que el servidor olvide todo "Sin estado" suena como si el servidor tuviera amnesia, y no es así. Sabe todo sobre tu cuenta.

Lo único que deja de mantener es una nota que diga que estás conectado en este momento. Esa nota es lo que el token reemplaza.

JunoSin estado no significa que el servidor olvide todo Este modelo es solo parcialmente "sin estado", y vale la pena decirlo con claridad. El servidor sigue manteniendo una tabla de búsqueda de token a usuario, así que el estado no desapareció, se redujo.

Un objeto de sesión guarda identidad, un rol y lo que se haya acumulado. La tabla de búsqueda guarda una sola fila: esta cadena significa este usuario. Eso es mucho más pequeño para almacenar, replicar y mantener sincronizado.

JunoSin estado no significa que el servidor olvide todo El nombre del encabezado es un vestigio real y engaña constantemente. El token viaja en Authorization: Bearer <token>, y en este modelo hace autenticación: demuestra quién eres y no otorga nada por sí mismo. Los permisos se siguen decidiendo después, a partir del usuario al que se resuelve.

Vale la pena recordarlo cuando trabajas en una base de código desconocida, porque el nombre del encabezado invita a asumir que poseer el token implica tener permiso. Eso nunca es cierto, y esa segunda verificación es la que la gente olvida escribir.

El flujo

Seis pasos, y la forma te resultará familiar:

  1. El usuario envía un nombre de usuario y una contraseña.
  2. El servidor los verifica.
  3. Si son correctos, el servidor genera un token aleatorio.
  4. El servidor guarda una pequeña asociación de token a usuario, y luego envía el token al cliente.
  5. El cliente lo guarda y lo adjunta a cada solicitud posterior en el encabezado Authorization.
  6. El servidor busca el token, encuentra al usuario, y la solicitud queda autenticada.
http
GET /api/orders
Authorization: Bearer 7f3a9c1e5b28d4a06e91f7c3

Comparado con sesiones y cookies, las diferencias se ubican en tres lugares:

Sesión con estadoToken portador opaco
El servidor almacenaUn objeto de sesión completoUna fila de token a usuario
El cliente almacenaUna cookie con un id de sesiónEl token mismo
ViajaAutomáticamente, gracias al navegadorExplícitamente, en código que tú escribes
Le conviene aAplicaciones web tradicionalesAPIs y apps móviles

Esa tercera fila importa más de lo que parece. Una cookie viaja sola sin que nadie la pida. Un token portador lo adjunta tu propio código de cliente, en cada solicitud, de forma deliberada.

JunoEl flujo Si esto se parece mucho a sesiones y cookies, es una reacción razonable. Algo lo guarda el cliente, algo lo verifica el servidor, y una búsqueda los conecta.

La diferencia está en cuánto guarda el servidor y quién se encarga de enviarlo. El mismo esqueleto, con el peso repartido de otra forma.

JunoEl flujo Enviar el token explícitamente es una ventaja, no una molestia. Una cookie se adjunta sola a cada solicitud hacia ese dominio, incluyendo las que dispara otro sitio. Eso es precisamente lo que hace posible el CSRF.

Un token portador lo adjunta tu código, así que una solicitud que tu código no generó no lleva nada. Por eso este modelo es la opción por defecto para APIs que sirven a clientes que no controlas.

JunoEl flujo El tercer paso trae un requisito que la gente suele saltarse. El token debe provenir de una fuente criptográficamente segura, crypto.randomBytes y no Math.random, con suficiente longitud como para que adivinarlo sea imposible. 32 bytes es el mínimo habitual.

El cuarto paso tiene algo que vale la pena adoptar: guarda un hash del token, no el token. El cliente conserva el original, el servidor guarda un resumen, y busca aplicando el hash a lo que llega.

Así la tabla de búsqueda se vuelve inútil para cualquiera que la lea, por la misma razón que las contraseñas nunca se guardan en texto plano. Muchos sistemas en producción guardan los tokens portadores en texto plano, y eso es lo primero que un atacante con acceso a la base de datos va a extraer.

Cinco formas en que esto falla

Casi ningún riesgo está en cómo funciona el modelo. Está en cómo se guarda el token, cuánto dura, y qué tan bien se cuida la tabla de búsqueda.

Qué fallaPor qué importaLa solución
1Almacenamiento inseguroUn token en localStorage, en una variable simple o en una cookie sin protección puede leerlo cualquier script en la página. Un solo bug de XSS y el atacante queda indistinguible del usuarioGuárdalo en un lugar más difícil de alcanzar, y limita cuánto se expone
2Tokens de vida largaUn token válido por semanas significa que un token filtrado sigue siendo válido por semanasVentanas de expiración cortas
3Sin revocaciónLos tokens no expiran solos ni dejan de funcionar al cerrar sesión. Una expiración que nadie verifica es solo decoraciónVerifica la expiración en cada solicitud, elimínalo al cerrar sesión
4Acumulación en la tabla de búsquedaCada inicio de sesión escribe una fila, así que las filas se acumulan y las búsquedas se vuelven lentas. Un atacante puede forzar esto bombardeando el endpoint de inicio de sesión, lo que constituye una denegación de servicioUna tarea programada que elimine los tokens expirados
5Una tabla de búsqueda manipuladaLa asociación es la fuente de verdad. El acceso de escritura mediante inyección SQL o credenciales filtradas puede redirigir un token hacia otro usuarioProtege la base de datos y las herramientas administrativas con el mismo cuidado que la aplicación

El problema 5 es el que vale la pena analizar con calma. Nada cambia en el token en sí; lo que cambia es lo que significa. Eso es una elevación de privilegios lograda sin tocar la credencial para nada.

JunoCinco formas en que esto falla Fíjate que cuatro de los cinco problemas no tienen nada que ver con el token en sí. Tienen que ver con dónde se guarda, cuánto dura, si alguien lo limpia, y quién puede editar la lista.

El token está bien. Todo lo que lo rodea es donde está el verdadero trabajo.

JunoCinco formas en que esto falla El problema uno no tiene una respuesta limpia, y ayuda entender por qué, porque mucho de lo que se dice en internet sugiere lo contrario.

localStorage puede leerlo cualquier script en la página. Una cookie con HttpOnly no, pero las cookies traen de vuelta el CSRF, así que necesitas SameSite y quizás una verificación adicional del token.

Ambos son diseños válidos con debilidades reales. La pregunta es contra qué ataque prefieres defenderte, y la respuesta honesta suele empezar por no tener un bug de XSS en primer lugar.

JunoCinco formas en que esto falla La forma en la que se termina resolviendo esto en producción es con un token de acceso de vida corta, cuestión de minutos, más un token de renovación de vida larga guardado en un lugar más difícil de alcanzar. El token de acceso se verifica constantemente y expira rápido; el token de renovación se presenta pocas veces y se puede revocar.

Eso reintroduce una búsqueda, pero al renovar en lugar de en cada solicitud. Es la concesión que vale la pena entender: estás recuperando la capacidad de revocación a una fracción del costo.

La rotación del token de renovación es lo que hace detectable un robo. Emite un nuevo token de renovación en cada uso e invalida el anterior, así un token robado que se reutiliza aparece como un token repetido, y en ese momento se puede revocar toda la familia de tokens.

Ponlo en práctica

Una API emite tokens así:

js
const token = Math.random().toString(36).slice(2)
tokens[token] = { userId: 4821 }
res.json({ token })

Y los verifica así:

js
const token = req.headers.authorization?.replace('Bearer ', '')
const entry = tokens[token]
if (!entry) return res.status(401).json({ error: 'Unauthorized' })
req.userId = entry.userId

Encuentra cuatro problemas.

Compara tus respuestas

1. Math.random no es criptográficamente seguro. Su resultado se puede predecir a partir de valores anteriores, así que un atacante que reúna algunos tokens puede deducir otros. crypto.randomBytes(32).toString('hex') es la solución, y solo este problema ya hace que todo el esquema se pueda falsificar.

2. No hay expiración en ningún lado. No se guarda nada sobre cuándo se emitió el token y nada lo verifica al llegar, así que todo token emitido es válido para siempre. Guarda una fecha de expiración y verifícala en cada solicitud.

3. Los tokens se guardan en texto plano. tokens[token] conserva la cadena original, así que cualquiera que lea ese almacenamiento puede usar cada token que contiene. Guarda un hash y busca aplicando el hash a lo que llega.

4. tokens es un objeto simple, así que la búsqueda es permeable. tokens['toString'] devuelve Object.prototype.toString, un valor verdadero (truthy), así que una solicitud con el token literal toString pasa la verificación if (!entry) sin problema.

entry.userId termina siendo undefined. Que esto se convierta en un error o en una solicitud autenticada como el usuario undefined depende del código que viene después. Usa un Map, Object.create(null) u Object.hasOwn().

El número 4 es el que parece inofensivo a primera vista. Los otros tres son cosas que hay que recordar; ese último es una propiedad de los objetos de JavaScript que ha aparecido en sistemas reales.

Hacia dónde va esto

La tabla de búsqueda es lo que hace viable este modelo y también lo que lo limita. Te da capacidad de revocación, pero significa que cada solicitud toca almacenamiento compartido, justo lo que la identidad sin estado se suponía que iba a evitar.

Los JSON Web Tokens llevan la idea hasta su conclusión eliminando la búsqueda por completo, y colocando la identidad dentro del propio token.