Qué hay dentro de un JWT y qué no protege
Publicado el 6/7/2026 · 17 min de lectura · Herramientas para desarrolladores
Daniel Okonkwo — Desarrollador front-end y redactor de Tecnología en Allin
Rendimiento web · Formatos de archivo
Verificado con 6 fuentes
Un JSON Web Token son tres cadenas codificadas en base64url unidas por puntos: cabecera, carga y firma. La firma demuestra que las dos primeras partes no las ha alterado nadie sin la clave. No las oculta. Toma este token HS256 real — eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsInJvbGUiOiJlZGl0b3IiLCJpYXQiOjE3ODY1NzkyMDAsImV4cCI6MTc4NjU4MjgwMCwiaXNzIjoiaHR0cHM6Ly9hbGxpbi5leGFtcGxlIiwianRpIjoiYTFiMmMzIn0.bu5qB6d_D3--WUj4b77tnh-wdH6FhKhLcbh2nvNmyl4 — y decodifica la sección central sin ninguna clave. Sale {"sub":"1234567890","name":"Ada Lovelace","role":"editor","iat":1786579200,"exp":1786582800,"iss":"https://allin.example","jti":"a1b2c3"}. Base64url es una codificación, no un cifrado. La primera parte nombra el algoritmo, la tercera es una etiqueta de 43 caracteres sobre las dos primeras, y el token entero son 264 caracteres que viajan en cada petición. Todo lo que pongas en una carga JWT — direcciones de correo, identificadores internos, banderas de permisos — es legible por el navegador, por cualquier proxy que registre la cabecera y por quien lea el token en una pantalla. De ahí dos consecuencias. No pongas nada confidencial en una carga; usa JWE si de verdad necesitas ocultar el contenido, que es otra especificación. Y recuerda que la firma no revoca: un token robado sigue valiendo hasta que pase su exp, y por eso los tokens de acceso de vida corta se emparejan con un token de refresco revocable.
Un JWT está firmado, no cifrado. Cualquiera que tenga el token puede decodificar la carga y leer todas sus reclamaciones. Aquí tienes un token real, decodificado sin ninguna clave, más los tres ataques que la firma debe frenar y el único problema que no puede resolver.
Tres partes, dos puntos
Todo JWT tiene el mismo esqueleto: cabecera, punto, carga, punto, firma. La cabecera es un objeto JSON diminuto que nombra el algoritmo — aquí {"alg":"HS256","typ":"JWT"}, que en base64url queda como eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. La carga es otro objeto JSON con las reclamaciones. La firma se calcula sobre las dos primeras partes codificadas unidas por su punto, y por eso nunca puedes reordenarlas ni reformatearlas: lo que se firmó son esos bytes exactos.
La RFC 7519 reserva siete nombres de reclamación, y conocerlos ahorra mucha reinvención. iss es el emisor, sub el sujeto, aud el público previsto, exp la caducidad, nbf el momento antes del cual no vale, iat la hora de emisión y jti un identificador único del token. Las tres reclamaciones temporales son valores NumericDate: segundos simples desde la época Unix, no milisegundos, lo que es una fuente fiable de errores de factor 1000 en JavaScript. En el token de arriba iat es 1786579200 y exp es 1786582800: una diferencia de 3 600, o sea una hora de vida.
El tamaño sube más rápido de lo que se espera. Esa carga son 137 bytes de JSON y se convierte en 183 caracteres al codificarse, porque base64 siempre cuesta cuatro caracteres de salida por cada tres bytes de entrada — un recargo del 33 %. El token entero son 264 caracteres, y viaja en cada petición. Cambia a RS256 con una clave de 2048 bits y solo la firma salta de 32 bytes en bruto a 256, es decir 342 caracteres base64url: el token casi se duplica. Meter un array de permisos en la carga es como los equipos acaban chocando con los límites de cabecera de los proxies.
Decodifícalo tú mismo: la carga está en claro
Copia la sección central del token — la ristra de caracteres entre los dos puntos — y decodifícala en base64url. Sin clave, sin biblioteca, sin permiso. Lo que vuelve es {"sub":"1234567890","name":"Ada Lovelace","role":"editor","iat":1786579200,"exp":1786582800,"iss":"https://allin.example","jti":"a1b2c3"}. Todas las reclamaciones, en texto plano, incluido el rol. Si hubiera sido una dirección de correo, un número interno de cliente, un nivel de suscripción o una bandera de funcionalidad, sería igual de visible.
La confusión es comprensible, porque el token parece texto cifrado. No lo es. Base64url existe para mover bytes arbitrarios por canales que solo toleran un juego de caracteres restringido — URL, cabeceras, nombres de archivo. No hay clave, así que no hay nada que guardar en secreto ni nada que romper. La firma protege la integridad: si alguien edita la carga, la tercera parte ya no verificará y el servidor rechazará el token. La integridad no es la confidencialidad, y un JWT solo entrega la primera.
La regla práctica se sigue directamente. Trata una carga JWT como un tablón público que resulta ser a prueba de manipulación. Pon identificadores, no secretos. Pon un identificador de usuario en vez de un correo; un nombre de rol en vez del razonamiento detrás; nada que no imprimirías por fuera de un sobre. Y guárdalo en consecuencia: una cookie HttpOnly, Secure, SameSite lo pone fuera del alcance de los scripts de página, mientras que localStorage se lo entrega a cualquier script que logre ejecutarse en tu origen.
Por qué base64url y no base64
El base64 estándar usa sesenta y cuatro caracteres que terminan en + y /, y rellena la salida con = hasta un múltiplo de cuatro. Los tres son hostiles a URL y cabeceras: + significa espacio en datos de formulario, / es un separador de ruta y = es un separador clave-valor en una cadena de consulta. Por eso la RFC 4648 define un alfabeto apto para URL que cambia + por -, / por _ y elimina el relleno del todo. Eso es base64url, y es lo que usan todas las partes de un JWT.
La firma del token de arriba lo enseña a la vista: bu5qB6d_D3--WUj4b77tnh-wdH6FhKhLcbh2nvNmyl4 contiene un guion bajo y tres guiones. Convertidos de vuelta a base64 estándar, esos mismos 32 bytes se leen bu5qB6d/D3++WUj4b77tnh+wdH6FhKhLcbh2nvNmyl4=. Si alguna vez decodificas una parte de un JWT con una función base64 normal y sale basura, es por esto: primero hay que sustituir - por + y _ por /, y volver a añadir el relleno si el decodificador lo exige.
Qué frena la firma y qué la derrota
Cambia un carácter de la carga y la firma ya no cuadra, porque HMAC-SHA-256 sobre la cadena cabecera.carga modificada produce algo completamente distinto. Volver a firmar la carga manipulada exige el secreto, que el cliente no tiene. Hasta ahí, bien. Los fallos históricos no son ataques contra la criptografía; son ataques contra el verificador, y los dos clásicos vienen de fiarse de la cabecera.
El primero es alg: none. La RFC 7515 define un JWS no asegurado cuya cabecera dice {"alg":"none"} y cuya parte de firma está simplemente vacía — el token termina en un punto desnudo. Las bibliotecas que leían alg de la cabecera y despachaban en consecuencia aceptaban ese token y trataban sus reclamaciones como verificadas. Un atacante reescribe la carga a su gusto, pone alg en none, quita la firma y entra. El arreglo no es rechazar la cadena none; es dejar de preguntarle al token qué algoritmo usar. El verificador ya sabe qué algoritmo y qué clave espera, y cualquier otra cosa se rechaza antes de seguir analizando.
El segundo es la confusión HS256 frente a RS256, y es más sutil. Con RS256 el servidor guarda una clave privada para firmar y publica la pública correspondiente para verificar. Si un verificador toma el algoritmo de la cabecera, un atacante puede poner alg en HS256 y firmar el token con la clave pública como si fuera un secreto HMAC. El verificador ejecuta entonces obedientemente HMAC con esa misma clave pública — que tiene, porque es pública — y la etiqueta cuadra. Funciona: usar como clave de HMAC-SHA-256 el texto PEM de una clave pública produce un token que un verificador ingenuo acepta. Fijar el algoritmo esperado cierra los dos ataques a la vez, y por eso la RFC 8725 lo pone como recomendación destacada.
Un tercer error no requiere criptografía alguna: verificar la firma y luego olvidarse de comprobar las reclamaciones. Un token estructuralmente válido cuyo exp pasó la semana pasada sigue siendo estructuralmente válido. Lo mismo uno emitido por otro inquilino o destinado a otro público. Comprueba siempre exp contra la hora actual con un pequeño margen de desfase de reloj, iss contra el emisor que esperas y aud contra tu propio identificador — la firma dice quién escribió las reclamaciones, no si siguen aplicándote.
El problema que ninguna firma resuelve: la revocación
Todo el atractivo de un JWT es que el servidor no tiene que consultar nada. El token lleva sus propias reclamaciones y su propia prueba, así que cualquier nodo con la clave lo verifica en microsegundos sin tocar una base de datos. Esa es también su debilidad estructural, y ambas cosas son inseparables: un servidor que no consulta estado no puede saber que despediste al usuario hace cinco minutos. El token sigue valiendo hasta que llegue exp, y nada en la especificación ofrece forma de acortarlo.
La respuesta estándar es un reparto en dos tokens. El token de acceso es un JWT con un exp deliberadamente corto — de cinco a quince minutos es el rango habitual — y se comprueba sin estado en cada petición. El token de refresco es de vida larga, opaco, almacenado en el servidor y se canjea por un nuevo token de acceso cuando el corto se agota. La revocación ocurre sobre el token de refresco, que sí tiene estado y por tanto es cancelable. La ventana de exposición se reduce a la vida restante del token de acceso, justo lo que exp se eligió para acotar.
Si necesitas una revocación más rápida, tienes que reintroducir estado, y conviene hacerlo a propósito. Una lista de denegación indexada por la reclamación jti permite cancelar tokens individuales; las entradas pueden eliminarse en cuanto pase el exp correspondiente, así que la lista se mantiene pequeña. Rotar la clave de firma invalida todos los tokens a la vez, que es el instrumento contundente ante una sospecha de compromiso de clave. Ambos te cuestan una consulta, y llegado ese punto es justo preguntarse si un identificador de sesión opaco en una cookie no habría sido más simple desde el principio.
JWE es la otra especificación, y no es un ajuste de JWT
Cuando las reclamaciones deben ocultarse de verdad, la respuesta es JSON Web Encryption, definida en la RFC 7516. Un JWE es una serialización distinta con cinco partes en vez de tres — cabecera protegida, clave cifrada, vector de inicialización, texto cifrado y etiqueta de autenticación — y aporta confidencialidad e integridad a la vez, porque usa cifrado autenticado. Puedes anidar ambos, firmando un JWT y cifrando después el resultado, que es lo que la RFC 7519 llama JWT anidado.
En la práctica, la mayoría de los equipos no necesita JWE y no debería recurrir a él primero. Si la carga contiene algo que preferirías que nadie leyera, la respuesta correcta habitual es sacarlo de la carga. Sustituye el valor sensible por un identificador opaco que el servidor de recursos sepa resolver, y el problema de confidencialidad desaparece junto con la gestión de claves extra, la superficie de biblioteca extra y los modos de fallo extra. Recurre a JWE cuando un token deba atravesar una parte que ha de reenviarlo sin leerlo — ese es el caso para el que se diseñó.
Una lista de comprobación para el verificador
Fija el algoritmo antes de analizar nada y rechaza un token cuya cabecera discrepe. Verifica la firma con una comparación en tiempo constante. Después comprueba exp, y nbf si está, contra la hora actual con una tolerancia de desfase de no más de un minuto. Comprueba iss contra el emisor exacto en que confías y aud contra tu propio identificador. Solo tras todo eso deberías leer las reclamaciones de aplicación y, aun así, trata role o scope como una afirmación del emisor y no como la última palabra: el servidor de recursos sigue siendo dueño de sus decisiones de autorización.
| Parte | Contenido | Tamaño en este token | ¿Legible sin clave? | ¿Cubierta por la firma? |
|---|---|---|---|---|
| Cabecera | alg y typ — qué algoritmo lo firmó | 36 caracteres | Sí, del todo | Sí — pero el verificador no debe creer a alg a ciegas |
| Carga útil | Las reclamaciones: sub, iss, exp, iat, jti y lo que añadas | 183 caracteres para 137 bytes de JSON | Sí — es lo que casi todos pasan por alto | Sí — no se puede editar sin la clave |
| Firma | HMAC-SHA-256 sobre cabecera.carga, o una firma RSA/ECDSA | 43 caracteres para 32 bytes en bruto | Sí, pero sola no significa nada | Es la firma |
| Lo que falta | La confidencialidad y cualquier forma de anular el token antes | Cero bytes se dedican a eso | No aplica | Usa JWE para lo primero, un token de refresco o una lista de denegación para lo segundo |
Preguntas frecuentes
- ¿Un JWT está cifrado?
- No. Un JWT estándar está firmado, que es otra garantía. La cabecera y la carga van codificadas en base64url, una codificación sin clave ni secreto, así que quien tenga el token puede leerlas. Decodificar la carga del token de ejemplo de este artículo sin ninguna clave devuelve {"sub":"1234567890","name":"Ada Lovelace","role":"editor","iat":1786579200,"exp":1786582800,"iss":"https://allin.example","jti":"a1b2c3"} — todas las reclamaciones en texto plano. Lo que la firma te compra es evidencia de manipulación: cambia un carácter y la tercera parte deja de verificar, y el servidor lo rechaza. Si de verdad necesitas ocultar el contenido, JSON Web Encryption (RFC 7516) es la especificación para eso, y es un formato aparte de cinco partes, no una bandera que se activa en un JWT. En la mayoría de los diseños lo mejor es mantener los valores confidenciales fuera de la carga y llevar un identificador opaco.
- ¿Se puede modificar la carga de un JWT sin el secreto?
- Puede cambiar los caracteres, pero el resultado no verificará — siempre que tu verificador esté bien escrito. La firma se calcula sobre la cadena exacta cabecera.carga, así que cualquier edición produce una discrepancia y el token se rechaza. Dos fallos del verificador anulan esa protección. El primero es fiarse del campo alg de la cabecera: un token que declaraba {"alg":"none"} con la parte de firma vacía fue históricamente aceptado por bibliotecas que despachaban según la cabecera, permitiendo al atacante reescribir la carga a voluntad. El segundo es la confusión HS256/RS256, donde el atacante pasa un token asimétrico a HMAC y lo firma con la clave pública del propio servidor, que el verificador usa entonces como secreto HMAC; eso produce de verdad una etiqueta coincidente. Ambos se cierran con la misma medida: decide en tu propio código el algoritmo y la clave esperados antes de analizar, y rechaza lo que discrepe. La RFC 8725 lo da como recomendación principal.
- ¿Cómo cierro la sesión de un usuario si un JWT no se puede revocar?
- Borrar el token en el cliente termina la sesión de ese navegador, y para muchos productos eso basta de verdad. No basta si el token pudo copiarse, porque un token firmado sigue valiendo hasta su exp haga lo que haga el cliente. La estructura estándar es un JWT de acceso de vida corta — de cinco a quince minutos — emparejado con un token de refresco opaco de vida larga guardado en el servidor. Cerrar sesión borra el token de refresco, así que la sesión no puede renovarse y muere dentro de la vida restante del token de acceso. Si necesitas más rapidez, añade estado a propósito: una lista de denegación indexada por jti cancela tokens individuales, y las entradas se purgan en cuanto pasa el exp correspondiente, así que nunca crece sin límite. Rotar la clave de firma mata de golpe todos los tokens en circulación y es la respuesta correcta ante una sospecha de compromiso. Cada una reintroduce una consulta, que es el precio de la revocación que querías.
- ¿Dónde debo guardar un JWT en el navegador?
- En una cookie marcada HttpOnly, Secure y SameSite, en casi todos los casos. HttpOnly pone el token fuera del alcance de JavaScript, así que un fallo de scripting entre sitios en cualquier punto de tu origen no puede leerlo ni enviarlo fuera; Secure lo mantiene fuera de conexiones en claro; SameSite bloquea la falsificación de petición entre sitios a la que si no te expondrían las cookies. localStorage es la alternativa habitual y la más débil, porque cualquier script que corra en tu página — incluido uno traído por una dependencia comprometida — puede leer todas sus claves. El argumento a favor de localStorage suele ser que el token debe adjuntarse a mano en llamadas de API de otro origen, algo que las cookies vuelven incómodo; es una restricción real, pero conviene resolverla con un proxy del mismo origen antes que haciendo el token legible por scripts. Elijas lo que elijas, mantén la carga libre de nada sensible: el almacenamiento decide quién puede robar el token, no quién puede leerlo una vez robado.
- ¿Cuánto debe durar un JWT?
- Lo bastante corto para que su vida incancelable sea una exposición aceptable. Como un token firmado sigue valiendo hasta exp pase lo que pase de tu lado, exp es toda la anchura de la ventana en la que un token robado aún funciona. De cinco a quince minutos es el rango habitual para tokens de acceso, y el token de ejemplo de este artículo usa una hora — iat 1786579200, exp 1786582800, una diferencia de exactamente 3 600 segundos. Cualquier duración que se mida en días es en la práctica una credencial permanente con una firma pegada. La vida larga la lleva el token de refresco, y puede ser larga justo porque es opaco, se guarda en el servidor y es revocable. Importan dos detalles de implementación. Los valores NumericDate son segundos, no milisegundos, así que comparar exp con Date.now() en JavaScript sin dividir entre 1000 es el fallo clásico. Y prevé una pequeña tolerancia de desfase de reloj, del orden de treinta a sesenta segundos, o algún token será rechazado por un servidor cuyo reloj va algo por delante del emisor.
- ¿Debo usar un JWT o una cookie de sesión normal?
- Para una sola aplicación con una base de datos, un identificador de sesión normal en una cookie suele ser la opción más simple y más sólida. Es una cadena opaca aleatoria, no revela nada, y cerrar sesión es borrar una fila con efecto inmediato. El JWT se gana su complejidad cuando la verificación debe ocurrir donde el almacén de sesiones no está — varios servicios tras una pasarela, una API de terceros que acepta tus afirmaciones de identidad, una función en el borde que no puede permitirse una ida y vuelta a la base. Es una ventaja arquitectónica real y es la razón de ser de los JWT. No adoptes uno para un monolito que ya tiene una tabla de sesiones, y sé honesto sobre el canje: la ausencia de estado te compra verificación distribuida rápida y te cuesta la revocación instantánea; no puedes quedarte con ambas. Los equipos que añaden una consulta de lista de denegación a cada petición han pagado la complejidad del JWT y devuelto su único beneficio estructural.
Artículos que podrían interesarte
Todas las guías →Herramientas relacionadas
Fuentes
¿Has detectado un error en este artículo?