Anatomía de un token
Este es un token HS256 completo, firmado con el secreto de demostración demo-secret-do-not-use. Los saltos de línea se han añadido para que se lea mejor; un token real ocupa una sola línea:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IkFkYSIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDM2MDB9
.nKTVXOM_r1JbE92WsFd2SSun0a1TByUV8ZpqrRUnIhk
Las tres partes, separadas por .:
| Parte | Longitud en Base64URL | Se decodifica como |
|---|---|---|
| Cabecera | 36 | {"alg":"HS256","typ":"JWT"} |
| Payload | 104 | {"sub":"user-42","name":"Ada","admin":false,"iat":1760000000,"exp":1760003600} |
| Firma | 43 | 32 bytes en bruto de HMAC-SHA256 |
La cabecera y el payload son JSON codificado en UTF-8 y después en Base64URL. La firma es binaria, así que solo tiene sentido como bytes. iat y exp son marcas de tiempo Unix en segundos: este token se emitió el 2025-10-09 a las 08:53:20 UTC y caducó una hora después.
Por qué Base64URL sin relleno
Un token viaja por sitios donde +, / y = dan problemas: query strings (+ se convierte en espacio), rutas de URL (/ es un separador), cookies y cabeceras HTTP. Por eso JWS, el formato de firma en el que se basa JWT, definido en el RFC 7515, usa el alfabeto URL-safe del RFC 4648 y quita el relleno:
Codificación Base64 con el juego de caracteres seguro para URL y nombres de archivo definido en la sección 5 del RFC 4648, omitiendo todos los caracteres ‘=’ finales (como permite la sección 3.2) y sin incluir saltos de línea, espacios en blanco ni otros caracteres adicionales.
De ahí salen dos cambios respecto al Base64 estándar:
+pasa a ser-y/pasa a ser_. La firma de arriba contiene_, que los decodificadores estándar rechazan.- No hay
=al final. La longitud de un segmento nunca es 4n+1, así que un decodificador siempre puede deducir cuánto relleno se quitó: si la longitud módulo 4 es 2, faltan==; si es 3, falta=.
Más sobre la diferencia de alfabetos en Base64 frente a Base64URL.
Decodificar las partes a mano
Divide por los puntos, restaura el alfabeto estándar y el relleno, decodifica y analiza el JSON. En el navegador:
function decodePart(part) {
const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
const [header, payload] = token.split(".").slice(0, 2).map(decodePart);
Sin el paso de TextDecoder, el texto no ASCII se rompe. Un payload con {"name":"Zoë 日本"} se codifica como eyJuYW1lIjoiWm_DqyDml6XmnKwifQ. Si pasas la cadena convertida solo por atob, el nombre sale como mojibake que empieza por Zoë, porque atob devuelve un carácter por byte. En los entornos que la tienen, Uint8Array.fromBase64(part, { alphabet: "base64url" }) sustituye las tres primeras líneas; consulta Uint8Array.toBase64. En Node.js, Buffer.from(part, "base64url") hace lo mismo.
En Python, urlsafe_b64decode se encarga del alfabeto, pero sigue queriendo el relleno:
import base64, json
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
header, payload, signature = token.split(".")
print(json.loads(b64url_decode(header))) # {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(b64url_decode(payload))) # {'sub': 'user-42', 'name': 'Ada', ...}
En una shell, traduce el alfabeto con tr y rellena con un bucle:
jwt_part() {
local s
s=$(printf '%s' "$1" | tr '_-' '/+')
while [ $(( ${#s} % 4 )) -ne 0 ]; do s="$s="; done
printf '%s' "$s" | base64 -d
}
jwt_part "$(echo "$TOKEN" | cut -d. -f2)"
Si solo necesitas ver qué hay dentro de un token, pégalo en el decodificador JWT. Se ejecuta en tu navegador, así que un token de producción no sale de tu máquina.
Por qué el payload se puede leer
Codificar no es cifrar. Base64URL solo cambia cómo se escriben los bytes, y el decodificador JWT lo revierte sin ninguna clave. Un JWT firmado garantiza que el payload no ha cambiado desde que se firmó. No oculta el payload.
La codificación también ocupa espacio. Cada claim crece un tercio al codificarlo en Base64URL, y el token entero viaja con cada petición en una cabecera o una cookie, así que mantén el payload pequeño: un ID y unos pocos claims cortos, no el perfil completo del usuario.
Trata todos los claims como públicos. Los ID de usuario, los roles y las fechas de caducidad están bien. Las contraseñas, las claves de API y los datos personales que quien tiene el token no debería ver, no. Si el contenido tiene que ser confidencial, usa un JWT cifrado (JWE). Su forma compacta tiene cinco partes en Base64URL: cabecera, clave cifrada, IV, texto cifrado y etiqueta de autenticación. La explicación codificación frente a cifrado cubre la diferencia en general.
Cómo se calcula la firma
La firma abarca el texto ASCII exacto header.payload, es decir, las dos cadenas Base64URL con el punto entre ellas. Para HS256:
import hmac, hashlib
signing_input = f"{header}.{payload}".encode("ascii")
mac = hmac.new(b"demo-secret-do-not-use", signing_input, hashlib.sha256).digest()
hmac.compare_digest(mac, b64url_decode(signature)) # True
Después, los bytes en bruto de la firma se codifican en Base64URL sin relleno. Su longitud depende del algoritmo:
| Algoritmo | Bytes de la firma | Caracteres en Base64URL |
|---|---|---|
| HS256 | 32 | 43 |
| ES256 | 64 | 86 |
| RS256 (clave de 2048 bits) | 256 | 342 |
Las firmas ES256 en JWS son los valores R || S en bruto, de 32 bytes cada uno. OpenSSL y muchas bibliotecas criptográficas producen en cambio una firma ECDSA codificada en DER, que empieza por 30 y tiene longitud variable. Convertir entre las dos es una fuente habitual de errores de «invalid signature».
Para crear y firmar un token con claves HS, RS, ES o EdDSA, usa el codificador JWT. Para un HMAC en bruto en Base64, consulta la herramienta HMAC.
Errores habituales
- Usar
atobo un decodificador Base64 estándar directamente. Funciona con algunos tokens y falla con otros, según aparezca o no un-o un_. El payload de nuestro ejemplo se decodifica conatob; la firma, no. - Añadir relleno al crear tokens. Algunas bibliotecas rechazan los segmentos que terminan en
=. Quítalo. - Volver a codificar la cabecera o el payload antes de verificar. La firma se calcula sobre el texto Base64URL original. Analizar el JSON y serializarlo de nuevo puede cambiar el orden de las claves o los espacios, y la firma deja de coincidir. Verifica siempre con los segmentos tal como llegaron.
- Confiar en un payload decodificado. Decodificar no es verificar. Comprueba la firma con el algoritmo esperado y nunca aceptes
"alg":"none"(cabeceraeyJhbGciOiJub25lIiwidHlwIjoiSldUIn0) salvo que sea lo que quieres. - Usar la cadena del token como clave única. 32 bytes de firma necesitan 256 bits, y 43 caracteres llevan 258. Los 2 últimos bits no se usan, así que en nuestro ejemplo la
kfinal se puede cambiar porl,mony los decodificadores tolerantes siguen devolviendo los mismos bytes. La firma sigue verificándose. No afecta a la seguridad, pero dos cadenas distintas pueden representar el mismo token. - Leer las marcas de tiempo como milisegundos.
expeiatestán en segundos. Multiplícalos por 1000 antes de pasarlos alDatede JavaScript.
Preguntas frecuentes
¿El payload de un JWT está cifrado?
No. Un JWT firmado normal (JWS) solo codifica el payload en Base64URL, así que cualquiera que tenga el token puede leerlo. Los tokens cifrados usan JWE, que tiene cinco partes en lugar de tres.
¿Por qué un JWT no termina con signos =?
El RFC 7515 define la codificación como Base64URL sin ninguno de los = finales, para que los tokens puedan ir en URL y cabeceras sin escaparlos. Los decodificadores tienen que volver a añadir el relleno o aceptar la entrada sin él.
¿Hace falta la clave secreta para decodificar las tres partes?
No. La cabecera y el payload son Base64URL sin más. La clave solo hace falta para verificar la firma o para crear una nueva. El decodificador JWT muestra ambas partes y comprueba la firma si le das la clave.
¿Por qué todos los JWT empiezan por eyJ?
La cabecera es un objeto JSON que empieza por {", y esos dos bytes siempre se codifican como eyJ. Hay más prefijos como este en prefijos Base64 habituales.