Skip to content
Volver al blog
Seguridad

JWT invalid signature: todas las causas y cómo corregirlas

Firma JWT inválida (invalid signature): bytes de clave según el lenguaje, saltos de línea en .env, algoritmo incompatible. Decodificador gratis.

14 min de lectura

JWT invalid signature: todas las causas y cómo corregirlas

Un error invalid signature en un JWT significa exactamente una cosa: la firma que calculó tu verificador no es igual a la firma que viaja dentro del token. Ese es todo el mensaje: no habla de expiración ni de permisos, y desde luego no dice que tu biblioteca JWT esté rota. Algo en los bytes que entran al HMAC, o en la clave pública que entra a la llamada de verificación, difiere entre el lado que firmó y el lado que comprueba.

Casi siempre el culpable es el material de clave, no el token. Usa esto para elegir por dónde empezar:

¿Qué algoritmo aparece en la cabecera?
├─ HS256 / HS384 / HS512  → casi siempre es un problema del secreto
│    ├─ ¿firmante y verificador en lenguajes distintos? → Sección 3
│    └─ ¿mismo lenguaje, funciona en local, falla en prod? → Sección 4
└─ RS256 / ES256 / PS256  → casi siempre el formato de la clave o una clave equivocada
     └─ → Sección 7

¿El token pasó por un gateway, un proxy o un copiar y pegar? → Sección 6
¿El error solo aparece tras unas horas o en un único host?   → Sección 8

Cada sección de abajo termina con algo que puedes ejecutar. Si tienes prisa, pega el token en el decodificador JWT y lee el campo alg: la mitad de las ramas de arriba se descarta en cuanto lo sabes.

1. Qué significa invalid signature

Cada biblioteca imprime una cadena distinta para el mismo fallo. Busca la tuya en esta lista para confirmar que estás en la guía correcta:

  • Node jsonwebtoken: JsonWebTokenError: invalid signature
  • Python PyJWT: InvalidSignatureError: Signature verification failed
  • Java jjwt: SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

Las tres se disparan en el mismo momento y en el mismo punto del código. La biblioteca toma los dos primeros segmentos de tu token, recalcula la firma con la clave que le pasaste y compara el resultado byte a byte contra el tercer segmento. Si no son iguales, lanza el error.

La comparación es exacta y no aporta ninguna información sobre cuánto difieren los dos valores. Un byte de diferencia en el secreto y una clave completamente equivocada producen mensajes de error idénticos. Por eso el resto de esta guía va de acotar las entradas posibles, no de leer el error con más atención.

Fíjate en lo que todavía no ha ocurrido cuando salta este error. La validación de claims corre después de la verificación de la firma, así que exp, nbf, aud e iss ni siquiera se han mirado. Si la verificación de la firma de tu JWT falló, el contenido del token es irrelevante para el diagnóstico, aunque sigue siendo legible, porque un JWT está codificado y no cifrado. Decodificar la cabecera y el payload no requiere ninguna clave; mira cómo decodificar un token JWT si quieres el recorrido segmento a segmento.

Dos campos de la cabecera deciden a dónde vas después: alg te dice si estás persiguiendo un secreto compartido o un par de claves, y kid te dice qué clave creía estar usando el firmante.

2. La firma cubre la cadena codificada, no tu objeto

El RFC 7515, la especificación de JSON Web Signature, define la entrada de firma (JWS Signing Input) como la cadena ASCII:

BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)

El HMAC se calcula sobre esa cadena, no sobre tu mapa de claims ni sobre nada que tu lenguaje considere datos estructurados. Esta es la entrada de firma que se usa a lo largo de todo el artículo, tomada del payload de ejemplo estándar:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

La consecuencia atrapa a los equipos una y otra vez: cualquier capa que decodifique el payload y lo vuelva a codificar destruye la firma. La serialización JSON no es canónica. El orden de las claves cambia cuando un mapa da la vuelta por casi cualquier lenguaje. Aparecen o desaparecen espacios en blanco. Un serializador escapa los caracteres no ASCII como \uXXXX y otro los emite literalmente. Los números se reformatean: 1516239022 puede volver como 1516239022.0. Cualquiera de esas cosas cambia la cadena base64url, y con ella la entrada de firma y la firma.

Cómo pasa en la práctica:

  • Un API gateway que analiza el JWT para enriquecerlo con un ID de tenant y vuelve a emitir el token.
  • Un middleware de logging o tracing que “normaliza” las cabeceras y reescribe el valor de Authorization.
  • Un desarrollador que formateó un token para leerlo con comodidad y después pegó de vuelta la versión formateada.

Si algún componente entre tu firmante y tu verificador puede reescribir el token, ese componente es el primer sospechoso. En tránsito, los tokens son cadenas opacas; las únicas operaciones seguras son guardar, copiar y comparar.

3. El mismo secreto, bytes distintos

De aquí salen los reportes del tipo “el secreto es literalmente idéntico, ya hice el diff”.

HMAC no consume una cadena. Consume bytes. Tu archivo de configuración, tu gestor de secretos y tus variables de entorno guardan cadenas. Algo tiene que convertir una cosa en la otra, y esa conversión no está estandarizada entre las bibliotecas JWT. Dos servicios pueden tener secretos idénticos carácter por carácter y aun así calcular firmas distintas.

Aquí está la prueba, calculada localmente contra la entrada de firma de la Sección 2. La cadena del secreto tiene 36 caracteres:

c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
Interpretación de los bytesBytesQué es la clave en realidadFirma HS256 resultante
Tratada como texto UTF-836los 36 caracteres visibles en sítUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
Decodificada primero desde base6427secret-key-1234567890abcdef53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM

Misma cadena de secreto, mismo algoritmo, mismo payload, y dos firmas que no se parecen en nada. El lado que lo hizo “mal” reporta invalid signature, y por más que compares el archivo de configuración no vas a encontrar nada, porque los archivos de configuración coinciden.

El token completo para la lectura UTF-8, por si quieres reproducirlo:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak

Pégalo en el decodificador JWT con el secreto de arriba y la verificación pasa. Decodifica antes ese secreto como base64 y deja de pasar.

Cómo convierte cada biblioteca una cadena en bytes de clave

La tabla de abajo solo recoge lo que está documentado, y la última columna importa más que la primera.

Runtime / bibliotecaComportamiento de cadena a bytesQuién decide
Node jsonwebtokenbytes UTF-8 de la cadenala biblioteca
Python PyJWTbytes UTF-8 de la cadenala biblioteca
Java jjwt, sobrecarga String heredadacódec base64 de la plataforma, según jwtk/jjwt#204la biblioteca
Go golang-jwtrecibe []byte directamente, en el punto de llamada
.NETrecibe byte[] directamente, en el punto de llamada

La fila de Java es la fuente histórica del dolor entre stacks. En versiones antiguas de jjwt, signWith(SignatureAlgorithm, String) y sus hermanas pasaban el String por un códec base64 en lugar de tomar sus bytes crudos, mientras que las sobrecargas con byte[] usaban los bytes tal cual. Por eso un servicio en Node y uno en Java que compartían un secreto no se ponían de acuerdo. Esa API con String está obsoleta desde jjwt 0.10, y la forma moderna es explícita:

SecretKey key = Keys.hmacShaKeyFor(secretBytes);

Esto no es “así hace Java los JWT”. Es la sobrecarga heredada de una biblioteca, y el código actual de jjwt que pasa un byte[] no tiene ninguna ambigüedad. El reporte espejo del lado de Node es auth0/node-jsonwebtoken#208, donde los tokens firmados en Java no verificaban en Node. Hay reportes parecidos contra firebase/php-jwt de PHP (ver firebase/php-jwt#153), aunque no hemos verificado cómo maneja los bytes esa biblioteca, así que tómalo como una pista y no como un diagnóstico.

Go y .NET van en otra categoría. Ninguna de las dos bibliotecas decide por ti; ambas te entregan el parámetro []byte / byte[] y se apartan. []byte(secret) y Encoding.UTF8.GetBytes(secret) dan UTF-8, mientras que Convert.FromBase64String(secret) da los bytes decodificados. El error, cuando ocurre, vive en tu punto de llamada, lo cual es una buena noticia: se ve en tu propio diff.

¿Mi secreto JWT es base64 o UTF-8?

No hay ninguna bandera en el token que te lo diga. Tienes que razonar sobre la cadena misma:

  1. ¿Usa solo A–Z a–z 0–9 + / = (o - y _)? Si es así, podría ser base64. Un secreto que contenga un espacio, un ! o un # no puede serlo.
  2. ¿Su longitud es múltiplo de 4, o termina con relleno =? Ambas cosas son indicios fuertes de que algo lo codificó en base64 en el camino de entrada.
  3. ¿Decodificarlo desde base64 produce bytes con sentido? Pásalo por el decodificador Base64. Un ASCII legible o exactamente 32 bytes de aspecto aleatorio sugieren base64. Un mojibake sugiere que la cadena nunca se codificó.

Un secreto como c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm cae en las tres pruebas, que es justo lo que lo vuelve peligroso: es ambiguo, y ambas lecturas son plausibles. Los secretos que contienen un - o un _ son ambiguos de una forma más desagradable, ya que son base64url válido pero base64 estándar inválido.

Cuando no puedas llegar a una respuesta razonando, calcula las dos. Toma la entrada de firma, pásale HMAC-SHA256 dos veces en el generador HMAC, una vez con el secreto como texto y otra con los bytes decodificados, y compara cada resultado contra el tercer segmento del token. Uno de los dos coincidirá, y eso te dice qué lado de tu sistema tiene razón.

Los caracteres no son bytes

La trampa emparentada es contar caracteres cuando el requisito está en bytes. El RFC 7518 §3.2 fija el mínimo de clave para HMAC-SHA en bits, no en caracteres, y el texto codificado se expande:

Cómo lo escribesEntropíaBytes equivalentesPara HS256 (necesita ≥256 bits)
32 caracteres hexadecimales128 bits16 bytes❌ por debajo del mínimo
32 caracteres base64192 bits24 bytes❌ por debajo del mínimo
32 bytes aleatorios256 bits32 bytes✅ lo cumple (64 caracteres en hex, 44 en base64 con relleno)

Un “secreto de 32 caracteres” puede tener entre 128 y 256 bits según el alfabeto. Esto es ortogonal al problema de interpretación de bytes de arriba, pero muerde a la misma gente, porque un equipo que mide en caracteres suele ser un equipo que nunca miró los bytes. Para las reglas de elección de secreto (longitud, codificación, rotación) ve al generador de secretos JWT; sus notas de referencia ya las cubren y no tiene sentido duplicarlas aquí.

4. El propio secreto se contaminó

Tus dos servicios coinciden en la interpretación de los bytes. La firma sigue fallando. Ahora toca comprobar si el secreto que cargó cada lado es el secreto que crees haber escrito, porque la fontanería del entorno es notablemente buena agregando un byte.

Salto de línea final en .env. JWT_SECRET=abc seguido de un salto de línea puede cargarse como abc\n en algunos lectores. Un byte extra, y el HMAC produce una salida completamente ajena. No hay ningún parecido parcial que puedas notar.

Comillas leídas como datos. JWT_SECRET="abc" significa abc para algunos cargadores y "abc" para otros, sobre todo cuando el archivo lo carga una shell frente a cuando lo analiza una biblioteca. El env_file de Docker Compose y un parser de .env pueden discrepar sobre el mismo archivo.

Caracteres invisibles al copiar y pegar. Copiar un secreto desde Slack, un wiki o un PDF puede arrastrar un espacio de ancho cero (U+200B, bytes e2 80 8b) o un espacio de no separación (U+00A0, bytes c2 a0). Ninguno se ve en el editor, y los dos cambian el HMAC.

Estropicios de CI y contenedores. Los secretos que pasan por interpolación de shell sufren expansión de $ o se comen las barras invertidas. Algunos sistemas de CI recortan los valores y otros no. Los secretos de Kubernetes están en base64 en el manifiesto y en crudo dentro del contenedor, una trampa de doble decodificación por sí sola.

La solución es dejar de mirar el secreto y empezar a medirlo. En cada lado, imprime la longitud y una huella, nunca el valor:

printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16

Ejecuta ambos comandos en el firmante y en el verificador y compara las dos salidas. Si la longitud y la huella coinciden, el secreto no es tu problema; vuelve a la Sección 3. Una longitud una unidad mayor de lo esperado es el salto de línea final. Una longitud dos unidades mayor son las comillas.

Cuando la longitud no cuadre y quieras ver exactamente qué hay ahí dentro, haz un volcado hexadecimal en una shell local contra un secreto de desarrollo:

printf '%s' "$JWT_SECRET" | xxd

Un 0a al final es un salto de línea. Un 22 al principio y al final es un par de comillas. Un c2 a0 o un e2 80 8b en medio es el caso del carácter invisible. No ejecutes esto contra un secreto de producción en una máquina que mande la salida de su terminal a cualquier parte.

La comprobación equivalente dentro de un proceso Node o Python en ejecución:

const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))

En Python, que len(s) cuente menos que len(s.encode("utf-8")) te dice que hay caracteres no ASCII en un secreto que debía ser ASCII.

5. El algoritmo y el tipo de clave no coinciden

La cabecera alg y la clave que pasas tienen que pertenecer a la misma familia. HS256 quiere un secreto compartido, que es una cadena de bytes. RS256 y ES256 quieren una clave asimétrica, que es un PEM o un JWK. Si cruzas esos cables, el fallo puede ser un error de tipos clarísimo o un simple invalid signature, según lo indulgente que sea la biblioteca.

Versiones habituales de esto:

  • La cabecera dice HS256 y el verificador le pasa a la biblioteca una clave pública PEM. Algunas bibliotecas hacen HMAC sobre el texto del PEM y reportan una firma que no coincide.
  • La cabecera dice RS256 y el verificador le pasa la cadena del secreto HMAC.
  • El verificador no pasa ninguna lista de algoritmos y deja que la biblioteca lo infiera de alg, así que una deriva de configuración en el lado que firma cambia en silencio lo que hace el verificador.

Ese último caso es donde un error de configuración se convierte en un error de seguridad, así que fija el algoritmo de forma explícita en cada llamada de verificación:

jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])

Fijarlo también convierte los errores vagos de firma en errores precisos. Si llega un token con alg: RS256 y tu lista de permitidos dice HS256, obtienes un error explícito de algoritmo que nombra ambos valores.

Conviene separar dos cosas que se parecen mucho. Lo que describe esta sección es mala configuración: dos componentes tuyos que no se ponen de acuerdo, sin ningún adversario de por medio. Existe un fallo emparentado con la misma forma en el que un atacante reescribe alg de RS256 a HS256 y firma con tu clave pública como secreto HMAC. Eso es confusión de algoritmos, es un ataque y no un bug, y está cubierto en buenas prácticas de seguridad JWT junto con el resto del modelo de amenazas. La defensa, una lista de permitidos explícita, es la misma en los dos casos, lo cual es un buen argumento para aplicarla incluso cuando solo persigues un bug.

6. El token cambió por el camino

Antes de culpar a las claves, confirma que el verificador recibió la misma cadena que produjo el firmante. Un JWT se rompe por las mismas razones por las que se rompe cualquier cadena de texto.

El prefijo Bearer. Authorization: Bearer eyJhbGci... es el valor de una cabecera, no un token. Cortar por donde no toca, o cortar una sola vez y quedarte con la mitad equivocada, te deja verificando Bearer eyJhbGci... o una cadena vacía. Quítalo a propósito:

const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();

Espacios en blanco y saltos de línea. Los tokens copiados desde una terminal se parten en varias líneas. Los tokens guardados en YAML se pliegan. Un solo \n incrustado dentro del tercer segmento produce una firma que no coincide, no un error de parseo, porque los decodificadores base64url suelen saltarse los espacios en blanco mientras que la comparación de cadenas no.

Codificación de URL. Un token que viajó como parámetro de consulta puede volver con . convertido en %2E, o con - y _ traducidos por un codificador demasiado entusiasta. Decodifica una vez, exactamente una vez.

Truncamiento. Las cookies tienen un tope de unos 4 KB cada una, y los tokens RS256 con unos cuantos claims lo superan con toda normalidad. Un token truncado suele fallar al decodificar base64, pero si el corte cae en un límite de 4 caracteres lo que obtienes es un token con buena pinta y la firma equivocada.

Dos comandos zanjan esto. Un JWT bien formado tiene exactamente dos puntos:

printf '%s' "$TOKEN" | tr -cd '.' | wc -c

Y todos los caracteres deben estar en el alfabeto base64url, de modo que esto no debería imprimir nada:

printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd

Cualquier salida del segundo comando nombra tu problema: 3d es relleno = que no debería estar ahí, 2b o 2f son el + y el / de base64 estándar donde base64url espera - y _, y 20 es un espacio perdido.

7. Fallos propios de RS256 y ES256

Los algoritmos asimétricos cambian el problema del secreto por un problema de gestión de claves. Los modos de fallo son lo bastante distintos como para merecer su propia lista.

PKCS#1 frente a PKCS#8. Son dos formatos contenedores para la misma clave RSA, y se distinguen a simple vista por una palabra en la línea de cabecera:

-----BEGIN RSA PRIVATE KEY-----      ← PKCS#1
-----BEGIN PRIVATE KEY-----          ← PKCS#8

Las bibliotecas varían en cuál aceptan. Cuando una rechaza el formato de plano obtienes un error claro; cuando lo parsea a medias puedes acabar con una firma que nunca verifica. Convierte en lugar de pelearte:

openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem

Las claves están intercambiadas. Firmar con la clave pública, o verificar con la privada. Obvio en principio, fácil de hacer cuando los dos archivos están en el mismo directorio con nombres que se diferencian en cuatro caracteres. Comprueba cuál es cuál:

openssl rsa -in key.pem -noout -text | head -1

Una clave privada imprime el tamaño de su módulo como clave privada; una clave pública da error a menos que agregues -pubin.

JWKS y deriva del kid. Con un endpoint JWKS, el verificador elige una clave haciendo coincidir el kid del token con el conjunto de claves. Aquí se tuercen tres cosas: el firmante rotó y el JWKS cacheado del verificador está obsoleto; el token no trae kid y el verificador toma la primera clave del conjunto; o dos entornos publican valores de kid solapados. Cuando sospeches de esto, descarga el JWKS fresco y confirma que el kid exacto de la cabecera del token está presente en él.

Codificación de la firma ES256. Las firmas ECDSA son un par de enteros, r y s, y hay dos maneras de serializarlos. Los stacks criptográficos de propósito general suelen emitir DER, una estructura ASN.1 de longitud variable. El RFC 7518 §3.4 exige en cambio la forma JOSE: r y s rellenados cada uno a una longitud fija y concatenados, que son 64 bytes para P-256. Una firma DER metida en un JWT está mal y además tiene otra longitud, así que un token ES256 cuyo tercer segmento no decodifique a exactamente 64 bytes lo construyó algo que se saltó la conversión.

Para aislar si el problema es tu clave o tu pipeline, firma el mismo payload por separado en el codificador JWT y compara la salida contra lo que produjo tu servicio. Firmas idénticas apuntan al transporte o al manejo de los claims. Firmas distintas apuntan a la clave.

8. Errores que parecen fallos de firma pero no lo son

A algunos los etiquetan mal las propias bibliotecas, y por eso acaban en el reporte de bug equivocado.

SíntomaQué es en realidadDónde mirar
ExpiredSignatureError de PyJWTexp está en el pasado. El nombre dice firma; la causa es un claim.Desfase de reloj entre hosts, o un TTL demasiado corto
ImmatureSignatureError de PyJWTnbf está en el futuroEl reloj del firmante va adelantado respecto al del verificador
TokenExpiredError de Nodeexp está en el pasadoLo mismo que arriba
401 genérico, sin detalleEl framework aplastó todos los fallos de verificación en una sola respuestaActiva el logging de errores a nivel de biblioteca
Funciona unos minutos y luego fallaExpiración del token, no la firmaCompara iat y exp contra los relojes de ambos hosts
Falla solo para una audienciaaud o iss no coincidenLa lista de audiencias esperadas del verificador

La nomenclatura de PyJWT es la peor trampa del grupo. ExpiredSignatureError contiene la palabra “signature” pero se lanza durante la validación de claims, mucho después de que la firma ya se haya verificado con éxito. Buscar la cadena del error lleva directo a material de diagnóstico de firmas, y las horas se esfuman en la sección equivocada del problema.

El desfase de reloj es lo que más despista: fallos intermitentes que no se correlacionan con nada de tu código. Si el reloj de un host se adelanta, los tokens recién emitidos fallan la validación de nbf o iat al llegar, y los fallos van cambiando a medida que crece la deriva. Compara date -u en ambas máquinas primero. Casi todas las bibliotecas aceptan un parámetro de tolerancia, que es la corrección adecuada para un desfase que no puedes eliminar y la corrección equivocada para un reloj que está de verdad roto.

La regla general: si el fallo depende del tiempo, del host o de la audiencia, no es un problema de firma. Los fallos de firma son deterministas. El mismo token y la misma clave fallan igual para siempre.

9. Un flujo de diagnóstico repetible

Ejecuta esto en orden. Cada paso encuentra el bug o elimina una rama, y parar pronto es justamente la idea.

  1. Decodifica la cabecera. Pega el token en el decodificador JWT y anota alg y kid. Esto decide todo lo que viene después y no necesita ninguna clave.
  2. Revisa la forma del token: exactamente dos puntos, solo caracteres base64url, sin prefijo Bearer, sin espacios en blanco. Usa los dos comandos de la Sección 6 y elimina la corrupción en el transporte.
  3. Fija el algoritmo en la llamada de verificación. Si hay una discrepancia entre alg y tu lista de permitidos, ahora obtienes un error explícito que nombra ambos en lugar de uno genérico.
  4. Saca la huella de la clave en ambos lados. Imprime la longitud en bytes y un SHA-256 truncado en el firmante y en el verificador, como en la Sección 4. Valores distintos significan que la culpa es de la fontanería y nunca llegas al paso 5.
  5. Si los dos lados están en lenguajes distintos, resuelve la interpretación de los bytes. Consulta la tabla de la Sección 3, decide explícitamente si el secreto es texto o base64, y haz que ambos lados lo declaren en el código en lugar de por defecto.
  6. Vuelve a firmar el mismo payload por tu cuenta. Usa el codificador JWT con la clave que crees correcta y compara su tercer segmento contra el de tu token. Que coincidan significa que tu lado de firma está bien y el problema es el verificador.
  7. Verifica el HMAC a mano. Pasa la entrada de firma por el generador HMAC con ambas interpretaciones de bytes. La que coincida con el token te dice qué lado hay que cambiar.

Si recorres los siete y sigues necesitando ayuda, vas a acabar pidiéndola en algún foro o issue. La mayoría de esos reportes se atascan porque omiten justo los datos que determinan la respuesta, así que incluye estos:

  • El valor de alg de la cabecera, y si hay un kid presente
  • Lenguaje, biblioteca y versión exacta en los dos lados, el que firma y el que verifica
  • La longitud en bytes del secreto en ambos lados, y los primeros 16 caracteres hexadecimales de su SHA-256 (nunca el secreto en sí)
  • Si el secreto se guarda como texto o como base64, y cómo lo convierte cada lado
  • La entrada de firma completa. Los dos primeros segmentos no son sensibles; quien tenga el token puede leerlos de todos modos
  • Para RS256 y ES256: la línea de cabecera del PEM, tal cual

Esa lista convierte un irresoluble “la firma de mi JWT no coincide” en una pregunta que alguien puede contestar, normalmente en una sola respuesta.

FAQ

¿Por qué el mismo secreto funciona en un lenguaje y falla en otro?

Porque las bibliotecas no se ponen de acuerdo en cómo convertir una cadena de secreto en bytes de clave. Node jsonwebtoken y Python PyJWT usan UTF-8; la sobrecarga heredada con String de jjwt usaba un códec base64 (jwtk/jjwt#204); Go y .NET dejan la decisión en tu punto de llamada. Mismos caracteres, bytes distintos, HMAC distinto.

¿La firma cubre el payload decodificado o la cadena codificada?

La cadena codificada. El RFC 7515 define la entrada de firma como base64url(header) + "." + base64url(payload) en ASCII literal. Cualquier capa que deserialice el payload y lo vuelva a serializar cambia el orden de las claves, los espacios en blanco o el formato de los números, y con eso cambia la cadena y la firma.

Mi secreto parece base64: ¿debo decodificarlo antes de firmar?

Solo si el otro lado también lo hace. No hay una respuesta correcta de forma aislada; el requisito es que ambos extremos coincidan. Comprueba si la cadena usa solo caracteres base64 y tiene una longitud múltiplo de cuatro, y después escribe esa decisión en el código de los dos lados en vez de confiar en los valores por defecto.

¿De verdad un salto de línea final en .env puede romper la firma?

Sí. HMAC consume bytes, y abc\n son cuatro bytes donde abc son tres. La firma resultante no comparte nada con la correcta. Imprime printf '%s' "$JWT_SECRET" | wc -c en ambos hosts; una longitud una unidad mayor de lo esperado es casi siempre esto.

¿Cómo sé si el problema es el secreto o el algoritmo?

Lee primero alg en la cabecera. Si empieza por HS, necesitas un secreto compartido y un PEM va a fallar. Si empieza por RS, PS o ES, necesitas un par de claves y una cadena de secreto va a fallar. Una vez que alg y el tipo de clave pertenecen a la misma familia, los fallos que queden son problemas del contenido de la clave.

¿Por qué jwt.io dice que la firma es válida pero mi servidor la rechaza?

Porque la herramienta online y tu servidor pueden interpretar el secreto de forma distinta: una como texto UTF-8 y el otro como base64. La herramienta valida contra los bytes que ella derivó, no contra los bytes que derivó tu servidor. Además, nunca pegues secretos de producción en un sitio de terceros; usa una clave de desarrollo.

¿Puede un token expirado causar un invalid signature?

No. La verificación de la firma corre antes de la validación de claims, así que la expiración nunca es la causa. La expiración aflora por separado como TokenExpiredError en Node o ExpiredSignatureError en PyJWT: el nombre de esta última engaña, porque la firma verificó perfectamente y lo único que falló fue exp.

Conclusión

Las firmas que no coinciden casi nunca son un problema de criptografía. HMAC-SHA256 funciona. RSA funciona. Lo que falla es la frontera donde una cadena se convierte en bytes: un códec base64 de un lado y UTF-8 del otro, un salto de línea que un cargador de configuración se quedó, un payload que un gateway volvió a serializar con la mejor intención. Casi todo lo que hay en esta guía se reduce a un desacuerdo sobre bytes.

Así que haz explícitos los bytes y deja de depender de los valores por defecto. Escribe, en la documentación de tu equipo, si el secreto compartido se guarda como texto crudo o como base64, y haz que cada servicio lo convierta de la forma declarada en vez de heredar lo que su biblioteca haya supuesto. Para sistemas repartidos entre varios lenguajes, guarda los secretos en hex o en base64 y decodifícalos explícitamente en cada punto de llamada: una línea por servicio, y la ambigüedad desaparece. Después agrega a tu health check la huella de longitud en bytes de la Sección 4, para que la próxima discrepancia aparezca como una advertencia de arranque y no como un 401 en producción.

Etiquetas: jwt authentication debugging hmac api-security

Artículos relacionados

Ver todos los artículos