¿Por qué mi JWT no es válido? Cómo decodificar y depurar un JSON Web Token
Un 401 que dice 'invalid token' casi nunca significa que el token esté corrupto: significa que una comprobación concreta entre tú y el servidor falló. En vez de coleccionar trucos de decodificación, entiende qué es realmente un JWT: texto plano firmado que siempre puedes leer, pero en el que solo debes confiar tras verificarlo. Entonces cada fallo se vuelve una lista corta y ordenada.
Tu petición vuelve con un 401 y un cuerpo que dice, sin ayudar en nada, {"error":"invalid_token"}. Tienes el token ahí mismo, en la cabecera Authorization —una cadena larga de aspecto opaco, con dos puntos en medio— y ni idea de qué le pasa. Así que empiezas a adivinar: regenerarlo, alargar la expiración, probar otra clave, reiniciar el servicio de autenticación. Cada intento es un disparo a ciegas, porque el error te dijo que algo falló, no cuál de las cosas.
Aquí está el replanteamiento que acaba con las adivinanzas: esa cadena no es opaca, y no está cifrada. Puedes leer ahora mismo cada byte de lo que afirma, sin clave, sin ayuda del servidor. Un JWT es texto plano firmado: el payload está a la vista, detrás de una codificación reversible, y la única parte que nadie puede falsificar sin la clave es la firma que prueba quién lo escribió (la firma en sí también viaja en claro junto al token; lo secreto es siempre la clave). En cuanto interiorizas ese único hecho, «¿por qué mi token no es válido?» deja de ser un misterio y se vuelve una pregunta corta y ordenada: de las pocas comprobaciones independientes que hace el servidor, ¿cuál dijo que no?
Este artículo no es una bolsa de trucos de decodificación. Dedica unos minutos a qué es de verdad un JWT —las dos preguntas que responde y mantiene estrictamente separadas— y luego lleva un solo token por cada forma en que puede ser rechazado: desde lo aburrido (caducó), pasando por lo sutil (los espacios en tu JSON rompieron la firma), hasta lo peligroso (el header le dijo al servidor qué algoritmo confiar, y el header lo controla el atacante). Al final tendrás una lista que puedes recorrer contra cualquier 401.
La única idea: decodificar y confiar son dos preguntas distintas
Casi toda la confusión con los JWT se remonta a mezclar dos preguntas que el estándar mantiene aparte a propósito:
- ¿Qué dice este token? — Siempre se puede responder. Cualquiera que tenga el token puede leer su contenido. No hace falta clave.
- ¿Debo creer lo que dice? — Solo se responde con la clave: en la práctica, quien la tiene, normalmente el servicio que recibe la petición.
Un JWT está construido para hacer la pregunta 1 trivial y la pregunta 2 rigurosa. El contenido está simplemente codificado —una transformación reversible que cualquiera puede deshacer— mientras que una firma criptográfica lo acompaña para responder la pregunta 2. La firma no oculta nada; no revuelve el payload. Es un sello a prueba de manipulaciones: cambia un solo carácter del contenido y el sello ya no cuadra, pero puedes leer el contenido cuadre el sello o no.
Por eso «¿es seguro un JWT?» es una pregunta trampa. El payload es más o menos tan privado como una postal —el cartero puede leerla— pero es tan evidente ante manipulaciones como un sello de lacre: sabrás si alguien lo reescribió. Sostén esta división. Cada sección de abajo trata en realidad de un lado o del otro: leer el token (pregunta 1) o las razones por las que el servidor se negó a confiar en él (pregunta 2).
La forma: tres segmentos Base64URL, separados por los puntos
Desarma el token antes que nada. Un JWT compacto son exactamente tres trozos unidos por dos puntos:
header . payload . signature
Cada uno de los dos primeros es un objeto JSON codificado en Base64URL. Separa por los puntos, decodifica los dos primeros segmentos y estás viendo JSON plano:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJ1c2VyXzQyIiwibmFtZSI6IkFkYSIsImV4cCI6MTcwMDAwMDAwMH0 . 3Rf...
El header se decodifica en algo como {"alg":"HS256","typ":"JWT"} —el tipo y, sobre todo, el algoritmo de firma—. El payload se decodifica en tus claims, p. ej. {"sub":"user_42","name":"Ada","exp":1700000000}. La firma son bytes en bruto, también en Base64URL, y no es JSON: no intentes leerla como texto; es la salida de una operación de hash o de firma.
Un matiz a «siempre puedes leerlo»: eso vale para un token firmado en formato compacto —un JWS—, que es el que te encuentras casi en todas partes. Los tokens JWE cifrados también existen; su forma compacta tiene cinco segmentos (cuatro puntos) en vez de tres, el payload sí es opaco sin la clave, y quedan fuera del alcance aquí. Así que si un «JWT» no se parte en tres segmentos legibles, sospecha de un JWE —o de un simple truncamiento o problema de formato— antes de seguir intentando decodificarlo.
Aquí aparece la primera trampa de verdad, y muerde a quien decodifica a mano o echa mano de la herramienta equivocada: Base64URL no es Base64. Es una variante diseñada para viajar segura por URLs y cabeceras, y difiere del Base64 estándar en tres cosas, cada una capaz de corromper tu decodificación en silencio:
- El
+se vuelve-, y el/se vuelve_—así que los dos caracteres con más probabilidad de aparecer en un hash son justo los que cambian—. - El relleno
=del final se elimina. Un decodificador de Base64 estándar a menudo exige ese relleno y fallará sin él. - No hay saltos de línea.
Por eso atob(segment) en un navegador, o un base64 -d a la ligera en la terminal, normalmente lanzan un error con un token perfectamente válido —el - y el _ son caracteres inválidos en el alfabeto estándar— y un decodificador lo bastante indulgente como para no lanzarlo te entrega los bytes equivocados. En cualquier caso, están hablando Base64 estándar a una cadena Base64URL. Tienes que devolver -/_ a +// y volver a rellenar hasta un múltiplo de cuatro antes de que un decodificador estándar coopere. Si quieres verlo en concreto, toma un solo segmento y pásalo por un codificador/decodificador Base64 en modo URL-safe frente al modo estándar: el estándar se atraganta o produce basura con la misma entrada que el URL-safe lee limpia. Este único desajuste es la razón más común de que un intento de «lo decodifico yo mismo» produzca disparates y mande a alguien por una vía de depuración completamente equivocada. (Ese desajuste Base64URL-frente-a-estándar —y cualquier otra razón por la que un Base64 se niega a decodificar— tiene su propio recorrido.)
Los claims que de verdad hacen que te rechacen
Supón que decodificaste el payload sin problemas. La mayoría de los rechazos invalid_token no son criptográficos en absoluto: son un claim que el servidor comprobó y no le gustó. Estos son los claims registrados de la RFC 7519, y unos cuantos son máquinas de rechazar:
exp(expiración) — la razón número uno, con diferencia. Es una marca de tiempo Unix en segundos; pasado ese instante el token debe rechazarse. Casi toda librería lo impone automáticamente, así que un token caducado suele caer antes de que tu código lo vea.nbf(not before, aún no válido) — la imagen espejo. El token todavía no es válido. Chocarás con esto cuando un token se emite para uso futuro o, mucho más a menudo, cuando el reloj del servidor que firma va por delante del que verifica.iat(issued at, emitido en) — cuándo se creó. No suele disparar un rechazo por sí solo, pero es el ancla para razonar sobre la antigüedad del token y el desfase de reloj.aud(audience, audiencia) — para quién es el token. Si tu API eshttps://api.example.comy elauddel token nombra a otro servicio, un verificador correcto lo rechaza aunque la firma sea perfecta. Que un token emitido para un servicio se reenvíe a otro es justo lo queaudexiste para frenar. Una versión concreta que ves en OIDC: un ID token (cuyoaudes tu aplicación cliente) enviado a una API que espera un access token (cuyoaudes la API) es un token perfectamente genuino usado en el lugar equivocado: firma correcta, audiencia incorrecta.iss(issuer, emisor) — quién lo firmó. Los verificadores lo comprueban contra una lista de emisores de confianza y lo usan para localizar el conjunto de claves que publica el emisor (su endpoint JWKS). Qué clave concreta de ese conjunto se usa lo decide elkidde la cabecera, no eliss.
La trampa escondida en todos los claims temporales: exp, nbf e iat son segundos epoch, no milisegundos. El Date.now() de JavaScript da milisegundos, así que una comparación hecha a mano que olvide dividir entre 1000 leerá todo token como no caducado hasta dentro de unos 54.000 años —o rechazará todo token recién emitido como ya muerto, según el lado que confundas—. Y como el valor es un entero pelado como 1700000000, tus ojos no distinguen de un vistazo si el token caducó la semana pasada o caduca el año que viene. Aquí es exactamente donde una herramienta se gana su sitio: pega el token en el inspector de JWT y exp, iat y nbf se muestran como fechas reales y legibles, con la hora absoluta al pasar el ratón —así «¿caducó?» y «¿están sincronizados los relojes de los dos servidores?» se vuelven cosas que ves en lugar de aritmética que haces de cabeza—. El desfase de reloj en particular es casi invisible en enteros epoch en bruto y evidente en cuanto están como fechas, una al lado de la otra.
La firma: es para verificar, y no se puede «decodificar»
Aquí la división de las dos preguntas se afila. La gente dice «decodifica la firma», pero la firma no es información codificada que puedas revertir en algo legible. Es la salida de pasar el header y el payload por una operación con clave, y lo único que puedes hacer con ella es recalcularla y comprobar si coincide. Una firma se verifica; nunca se decodifica.
Cuál es esa operación depende del alg del header, y la división corre por una falla que vale la pena entender:
- HS256 / HS384 / HS512 — simétrica (HMAC). La firma es
HMAC-SHA256(secret, header.payload). El mismo secreto compartido firma y verifica. Simple, rápido, y con un filo: cualquiera que pueda verificar un token HS256 también puede falsificar uno, porque verificar y firmar usan la misma clave. El HMAC de aquí es exactamente la primitiva HMAC-SHA256 que usarías para firmas de webhooks o de peticiones de API: una firma JWT es ese mismo resumen con un formato de entrada estandarizado. - RS256 / ES256 / PS256 / EdDSA — asimétrica. El emisor firma con una clave privada; todos los demás verifican con la clave pública correspondiente. Esta es la verdadera razón por la que los sistemas grandes prefieren RS256: el servidor de autenticación guarda la clave privada y nadie más puede acuñar tokens, mientras que cien servicios río abajo verifican cada uno con una clave pública distribuida libremente y aun así no pueden falsificar nada. Si quieres sentir la forma de ese par de claves, genera uno con el generador de claves RSA: la privada firma, la pública verifica, y no son intercambiables.
En el lado asimétrico hay una pieza más que provoca una parte sorprendente de las caídas reales. El verificador normalmente no guarda la clave pública directamente: obtiene el JWKS del emisor —un conjunto de claves publicado— y el kid de la cabecera dice con cuál de ellas se firmó este token. Los emisores rotan esas claves cada cierto tiempo. Si tu servicio cachea el JWKS y ocurre una rotación, el kid de los tokens nuevos puede apuntar a una clave que aún no has descargado, y todos los tokens empiezan a fallar la verificación a la vez, aunque no tengan nada malo. Cuando los tokens de toda una flota se invalidan de golpe, sospecha de una rotación de claves y un JWKS cacheado y obsoleto antes que de los tokens.
La consecuencia crítica, y el meollo de esta sección: decodificar el payload no prueba nada sobre la confianza. Puedes leer los claims de un token a la perfección y la firma seguir sin valer nada —clave incorrecta, sin clave, contenido manipulado—. Un buen inspector es honesto con esta distinción. Cuando pegas un token sin clave, el inspector de JWT muestra su estado como «Decoded, not verified» (decodificado, no verificado) —deliberadamente ni verde ni con una marca de comprobación— precisamente para que no confundas «lo puedo leer» con «puedo confiar en él». Solo tras aportar el secreto o la clave pública correspondiente pasa a «Signature verified» (firma verificada). Esa disciplina en las etiquetas es la idea de las dos preguntas hecha visible.
La sutil: la firma cubre los bytes exactos, no el JSON
Esta hace tropezar incluso a quien entiende todo lo anterior, así que merece su propio momento. La firma no se calcula sobre «el header y el payload como estructuras de datos»: se calcula sobre el texto Base64URL exacto de los dos primeros segmentos, carácter por carácter, incluido el punto entre ellos. La entrada de la firma es literalmente la cadena ASCII base64url(header) + "." + base64url(payload).
Lo que significa: si decodificas un payload, formateas el JSON, lo vuelves a codificar y pegas de nuevo la firma vieja, el token queda inválido, aunque no hayas cambiado nada semánticamente. Reordenar claves, añadir espacios, o un decodificador que reserializa {"a":1} como { "a": 1 } producen todos bytes distintos, y bytes distintos significan una firma distinta. Esto es por diseño —es lo que hace el sello evidente ante manipulaciones— pero explica un fallo genuinamente desconcertante: «solo lo reformateé y ya no verifica». No cambiaste el significado, pero cambiaste los bytes, y la firma solo prometió algo sobre los bytes. Es también por eso que no puedes editar un JWT a mano: para cambiar un claim de forma honesta debes volver a firmar, lo que requiere la clave. (Para eso está el modo sign del inspector: editas el JSON, aportas la clave y obtienes un token de verdad refirmado, no un empalme roto.)
La peligrosa: el header le dice al servidor cómo confiar en él
Ahora la parte que convierte un artículo de depuración en uno de seguridad, y siembra hacia dónde va esta serie. Mira otra vez dónde vive el alg: en el header. Que es parte del token. Que lo aporta quien envió el token. La instrucción de cómo verificar la confianza está dentro de la mismísima cosa cuya confianza está en cuestión.
Dos ataques clásicos salen directos de ahí:
alg: none. La especificación define un JWT «sin asegurar» sin firma alguna. Un verificador ingenuo que lee elalgdel header y hace lo que dice verá"alg":"none", saltará por completo la comprobación de firma y aceptará un token que un atacante escribió a mano. La solución es que el servidor decida qué algoritmos son aceptables, desde su propia configuración: nunca tomar elalgcomo una instrucción del token.- Confusión RS256 → HS256. Si un servidor verifica «lo que diga el
alg» y tiene una clave pública RSA, un atacante puede fabricar un token con elalgcambiado aHS256y firmarlo usando esa clave pública como secreto HMAC. La clave pública es pública, así que el atacante tiene todo lo necesario para falsificar. La librería, a la que se le dijo que hiciera HMAC, verifica obedientemente. La solución, otra vez: fija el algoritmo esperado del lado del servidor; no dejes que el token elija.
Para no exagerar: las librerías principales llevan años defendiéndose de ambos. Rechazan alg: none salvo que te esfuerces en permitirlo, y te obligan a nombrar de antemano los algoritmos que aceptas en vez de fiarte del alg del propio token. Así que esto no es motivo para entrar en pánico con cada JWT: es motivo para desconfiar de verificadores hechos a mano, librerías muy desactualizadas, y cualquier configuración que meta el propio alg del token en la lista de algoritmos aceptados.
El hilo es el mismo de todo el artículo. El token puede decir cualquier cosa —alg, exp, sub, admin: true— porque el payload y el header son solo texto plano que cualquiera puede escribir. La seguridad viene enteramente de que el servidor se niegue a tratar los claims del propio token como instrucciones, y de una comprobación de firma que él controla. Esta costura es la que abre el próximo artículo de la serie: por qué nunca debes confiar en un payload que no has verificado, y cada forma en que esa confianza se elude. Todo lo anterior fue «cómo leer un token»; eso es «cómo no dejarte engañar por uno».
La lista para un 401 de verdad
La próxima vez que un token sea rechazado, no regeneres a ciegas. Recorre esto en orden —aísla casi todos los casos—:
- Léelo primero. Pega el token en el inspector de JWT y mira el header y el payload decodificados. No adivines lo que hay dentro: míralo. La mayoría de las veces la respuesta está aquí, en un claim.
- Comprueba
exp(ynbf). ¿Están en el pasado / futuro? Léelos como fechas, no como enteros, y compara los relojes del que firma y del que verifica: el desfase es el culpable silencioso. Recuerda: segundos, no milisegundos. - Comprueba
audeiss. ¿La audiencia nombra a tu servicio, y el emisor es uno en el que tu servidor confía? Una firma perfecta igual falla estos. - Comprueba el transporte. ¿Hay un prefijo
Bearercolado, espacios al final o un salto de línea que se copió al valor de la cabecera? El token que verifica en una herramienta puede fallar por el cable por un solo carácter invisible. - Verifica la firma a propósito. Aporta el secreto (HS) o la clave pública (RS/ES/EdDSA) correspondiente y confirma que pasa de «Decoded, not verified» a «Signature verified». Si no lo hace, las causas habituales son: clave incorrecta, el
algdel header no coincide con tu familia de claves, o los bytes se alteraron tras firmar (ver la trampa del reformateo de arriba). En RS/ES/EdDSA, añade un sospechoso más: una clave de firma rotada con un JWKS cacheado y obsoleto —si elkidde la cabecera ya no resuelve a una clave que tengas, la verificación falla por razones que no tienen nada que ver con este token en concreto. - Confirma que
alges el que esperas —y que lo fija tu servidor en lugar de leerlo del token—. Si dicenone, o un algoritmo HMAC donde esperabas RSA, eso no es un bug que esquivar; es una bandera roja.
Dos reglas hacen que los seis pasos se sostengan. Leer un token no necesita clave; confiar en uno siempre la necesita —mantén eso separado y la mitad de la confusión se evapora—. Y como el payload lo puede leer cualquiera, nunca metas un secreto dentro: un JWT está firmado, no cifrado, así que trata cada claim como público. En tokens RS/ES la clave pública de verificación viene casi siempre del JWKS del emisor, pero a veces te la entregan como un certificado X.509 —y cuando es así, puedes leer el sujeto y la validez de ese certificado con el decodificador de certificados. (Leer un certificado te dice qué contiene, no que su emisor merezca estar en tu lista de confianza: esa decisión sigue siendo tuya.) Pero todo empieza igual: deja de tratar el token como un bloque opaco que solo puedes regenerar, y empieza a leer el texto plano que tuviste delante todo el tiempo.