🔐Ciberseguridad

JWT: decodificar no es validar

Felipe••15 min de lectura
Aprende a validar JWT con jose en Node.js: firma, JWKS, issuer, audience y expiración. Distingue JWS de JWE y evita confiar en payloads sin verificar.

Un token llega a tu API. Lo decodificas, encuentras un identificador de usuario y un campo que dice "role": "admin". El JSON parece correcto. ¿Eso demuestra que la persona tiene permisos de administrador?

No. Leer una afirmación no demuestra quién la emitió ni que siga siendo válida. Esa distinción es el centro de esta guía: veremos cómo está construido un JWT y ejecutaremos una validación con jose que compruebe tanto su protección criptográfica como el contexto en el que nuestra aplicación puede aceptarlo.

JWT, JWS y JWE: nombres distintos para piezas distintas

JWT significa JSON Web Token. Es una representación de afirmaciones, llamadas claims, que una parte comunica a otra. Un claim es un dato con significado acordado: quién emitió el token, a quién se refiere o cuándo caduca. RFC 7519.

JWS, JSON Web Signature, protege contenido mediante una firma digital o un código de autenticación de mensajes (MAC). Para un JWT firmado, ese contenido es el conjunto de claims. La protección permite detectar modificaciones y verificar el token con una clave de confianza; no oculta el contenido. RFC 7515.

JWE, JSON Web Encryption, representa contenido cifrado. Su serialización compacta tiene cinco partes: cabecera protegida, clave cifrada, IV, texto cifrado y etiqueta de autenticación. Según el algoritmo, alguna parte puede estar vacía. Un JWT puede estar cifrado como JWE e incluso contener un JWT firmado en su interior. RFC 7516.

Por tanto, no todos los JWT están cifrados. En este artículo trabajamos con JWT firmados como JWS. La función jwtVerify() de jose corresponde a ese caso; un JWT cifrado requiere el procesamiento de JWE, por ejemplo con jwtDecrypt(). Documentación de decodeJwt.

Las tres partes del JWT compacto firmado

Un JWT en serialización compacta JWS tiene esta forma:

cabecera.payload.firma

Las tres partes usan Base64url, una codificación apta para URL, y se separan con puntos:

ParteContenidoEjemplo de información
Cabecera protegidaParámetros de la operación criptográficaalg, kid, typ
PayloadJSON con los claimsiss, sub, aud, exp
Firma o MACResultado criptográficoBytes codificados; no otro objeto JSON

La firma protege la representación codificada de la cabecera y del payload, unidos por un punto. Cambiar el payload sin producir una firma válida para ese nuevo contenido debe provocar el rechazo. RFC 7515, sección 3.1.

alg identifica el algoritmo; kid ayuda a seleccionar la clave; typ permite expresar el tipo de token. Que la cabecera se llame protegida describe qué cubre la firma: no implica que sea confiable antes de verificarla. RFC 7515, sección 4.1.

Base64url se puede decodificar sin una clave. No coloques contraseñas o secretos en el payload de un JWS esperando que los puntos o la codificación los escondan. Si necesitas repasar la diferencia, consulta hashing, cifrado y codificación.

Decodificar, verificar y autorizar

Son operaciones relacionadas, pero responden preguntas diferentes:

OperaciónPregunta que resuelve
Decodificar¿Qué datos contiene esta representación?
Verificar la firma¿La protección criptográfica es válida con una clave que aceptamos?
Validar los claims¿Este token cumple el contrato de nuestra aplicación?
Autorizar¿Esta identidad puede realizar esta acción sobre este recurso?

decodeJwt() analiza los claims sin verificar la firma ni validar sus tipos y valores. Puede servir para inspeccionar datos de prueba, pero su resultado no debe decidir permisos. jwtVerify() combina la verificación del JWS con la validación del conjunto de claims según las opciones configuradas. Documentación de decodeJwt, documentación de jwtVerify.

Imagina que alguien reemplaza sub por otro identificador y vuelve a codificar el JSON. El resultado puede seguir siendo perfectamente legible. Nuestro laboratorio hará precisamente eso con datos ficticios: comprobará que decodificar funciona y verificar falla.

Claims registrados: significado y contrato

Estos siete claims están definidos en RFC 7519:

ClaimSignificado
issEmisor del token (issuer).
subSujeto al que se refiere (subject).
audDestinatario o destinatarios previstos (audience).
expMomento a partir del cual no se acepta.
nbfMomento antes del cual no se acepta.
iatMomento de emisión.
jtiIdentificador del token.

Los tiempos usan NumericDate, segundos desde el inicio de la época Unix, no milisegundos. El RFC no exige estos siete campos para todos los usos: cada aplicación define su perfil. Si aparece aud, el destinatario debe estar incluido. RFC 7519, secciones 2 y 4.1.

En nuestro perfil exigiremos iss, aud, sub, exp e iat. nbf será opcional, pero se comprobará si aparece. Limitaremos además la antigüedad a cinco minutos. Son decisiones de este ejemplo, no una duración universal exigida por JWT.

Con jose, configurar issuer y audience exige esos claims; maxTokenAge exige iat. Añadimos exp a requiredClaims porque comprobar una expiración presente no equivale a exigir que exista. clockTolerance: 5 admite cinco segundos de desfase en las comprobaciones temporales aplicables. Opciones de validación de claims.

Algoritmos: la política la define el servidor

Estos identificadores describen mecanismos distintos:

AlgoritmoMecanismoClaves
HS256HMAC con SHA-256El mismo secreto genera y verifica el MAC.
RS256Firma RSA PKCS#1 v1.5 con SHA-256Clave privada para firmar; pública para verificar.
PS256Firma RSA-PSS con SHA-256Pareja de claves privada y pública.
ES256Firma ECDSA con P-256 y SHA-256Pareja de claves privada y pública.

Técnicamente, HMAC es un MAC, no una firma digital asimétrica. Cualquier servicio que conozca el secreto HMAC puede generar tokens aceptables para los demás servicios que comparten ese secreto. En los esquemas asimétricos, distribuir la clave pública no entrega la capacidad de firmar. RFC 7518, sección 3.

Usaremos ES256 y una lista permitida explícita: algorithms: ['ES256']. No calcularemos esa lista a partir del alg que nos envíe el cliente. RFC 8725 exige que las bibliotecas permitan restringir los algoritmos y que cada clave se use con el algoritmo previsto. RFC 8725, sección 3.1.

Esto no significa que los otros algoritmos de la tabla sean incorrectos. La elección depende del emisor, las claves y el perfil acordado. Nuestro verificador rechaza none; jose tampoco acepta JWT sin protección en esta operación. Opciones de verificación JWS.

JWKS: claves públicas desde una fuente de confianza

Una JWK, JSON Web Key, representa una clave en JSON. Un JWKS, JSON Web Key Set, agrupa claves en un objeto con una propiedad keys. Para este ejemplo publicaremos únicamente la clave pública de verificación; la privada permanecerá en memoria. RFC 7517, sección 5.

createRemoteJWKSet() obtiene ese conjunto y selecciona una clave compatible, utilizando parámetros como alg y kid. Mantiene caché y limita cuándo un identificador desconocido puede provocar otra descarga. Crearemos el resolver una sola vez para reutilizarlo. Documentación de createRemoteJWKSet.

La URL de JWKS debe proceder de configuración confiable. No construiremos una URL arbitraria con iss, jku o x5u leídos de un token sin verificar: eso puede introducir claves ajenas o peticiones hacia destinos controlados por terceros. kid selecciona una clave dentro de la fuente permitida; no establece confianza por sí solo. RFC 8725, secciones 3.8 y 3.10.

Laboratorio completo en Node.js

Necesitas Node.js 22 o superior y pnpm. Usaremos jose 6.2.14, la versión comprobada para este laboratorio. Crea una carpeta independiente del proyecto de tu aplicación:

mkdir jwt-lab
cd jwt-lab

printf '{"private":true,"type":"module"}\n' > package.json
pnpm add --save-exact [email protected]

Guarda los tres bloques JavaScript siguientes, en orden, dentro de un único archivo jwt-lab.mjs. Separamos preparación, validación y pruebas para poder leerlos con calma.

1. Preparar el emisor y su JWKS local

Generamos una pareja de claves nueva y levantamos un pequeño servidor que solo escucha en 127.0.0.1, con un puerto libre asignado por el sistema. Los valores .example son identificadores ficticios; no se consultan por red.

HTTP se usa exclusivamente para este servidor local de prueba. Una integración real debe obtener las claves del endpoint HTTPS configurado de su emisor. La aplicación que consume tokens no debe generar una nueva clave de firma en cada arranque como hace este laboratorio.

import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import {
  SignJWT,
  createRemoteJWKSet,
  decodeJwt,
  exportJWK,
  generateKeyPair,
  jwtVerify,
} from 'jose';

const ALGORITHM = 'ES256';
const KEY_ID = 'clave-laboratorio';
const ISSUER = 'https://emisor.example';
const AUDIENCE = 'urn:uncodigo:api-laboratorio';
const TOKEN_TYPE = 'uncodigo-lab+jwt';

const { publicKey, privateKey } = await generateKeyPair(ALGORITHM);
const publicJwk = await exportJWK(publicKey);

const jwks = {
  keys: [{ ...publicJwk, kid: KEY_ID, alg: ALGORITHM, use: 'sig' }],
};

const server = createServer((request, response) => {
  if (request.method !== 'GET' || request.url !== '/jwks.json') {
    response.writeHead(404).end();
    return;
  }

  response.writeHead(200, { 'Content-Type': 'application/json' });
  response.end(JSON.stringify(jwks));
});

server.listen(0, '127.0.0.1');
await once(server, 'listening');

const { port } = server.address();
const jwksUrl = new URL(`http://127.0.0.1:${port}/jwks.json`);
const trustedKeys = createRemoteJWKSet(jwksUrl, { timeoutDuration: 2000 });

2. Verificar antes de devolver una identidad

El verificador acepta únicamente ES256, exige emisor y destinatario conocidos, comprueba el tipo de token y aplica las restricciones temporales. El tipo uncodigo-lab+jwt es una convención privada de este laboratorio, no un perfil estándar de tokens OAuth.

El límite de 8192 caracteres es una política ilustrativa para acotar la entrada; debe adaptarse al contrato real y acompañarse de límites en el servidor HTTP. Solo devolvemos los campos que el consumidor necesita, después de comprobar que sub es una cadena no vacía.

async function validarToken(token) {
  if (typeof token !== 'string' || token.length === 0 || token.length > 8192) {
    throw new Error('Token no aceptado');
  }

  try {
    const { payload } = await jwtVerify(token, trustedKeys, {
      algorithms: [ALGORITHM],
      issuer: ISSUER,
      audience: AUDIENCE,
      typ: TOKEN_TYPE,
      requiredClaims: ['iss', 'aud', 'sub', 'exp', 'iat'],
      maxTokenAge: '5 minutes',
      clockTolerance: 5,
    });

    if (typeof payload.sub !== 'string' || payload.sub.trim() === '') {
      throw new Error('Sujeto ausente o inválido');
    }

    return { issuer: payload.iss, subject: payload.sub };
  } catch (cause) {
    throw new Error('Token no aceptado', { cause });
  }
}

Si la firma, los claims o la descarga de claves fallan, no se devuelve identidad. Conservamos la causa para diagnóstico interno; al cliente no se le debe enviar el objeto de error ni su traza, solo una respuesta genérica.

En una API real conviene distinguir internamente un token rechazado de un fallo de infraestructura de JWKS, para registrar métricas y responder según la política del servicio. Un error de red nunca debe activar una alternativa basada en decodeJwt(). El tiempo máximo de descarga de nuestro resolver es de dos segundos. Opciones del resolver remoto.

3. Emitir datos ficticios y comprobar los rechazos

Estas pruebas no atacan un servicio externo: el propio laboratorio emite los tokens y conserva las claves de prueba. assert.rejects() hace que la ejecución falle si un caso inválido llega a aceptarse.

const now = Math.floor(Date.now() / 1000);
const claims = {
  iss: ISSUER,
  aud: AUDIENCE,
  sub: 'usuario-demo',
  iat: now,
  exp: now + 300,
};

async function emitirToken(payload, key = privateKey, header = {}) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: ALGORITHM, kid: KEY_ID, typ: TOKEN_TYPE, ...header })
    .sign(key);
}

async function esperarRechazo(nombre, token) {
  await assert.rejects(() => validarToken(token), { message: 'Token no aceptado' });
  console.log(`Rechazado: ${nombre}`);
}

try {
  const validToken = await emitirToken(claims);
  const identity = await validarToken(validToken);

  assert.equal(identity.subject, 'usuario-demo');
  console.log('Aceptado: token válido');

  // Solo para demostrar el problema: este contenido NO está verificado.
  const [header, , signature] = validToken.split('.');
  const changedPayload = Buffer.from(
    JSON.stringify({ ...decodeJwt(validToken), sub: 'administrador-demo' }),
  ).toString('base64url');

  const tamperedToken = `${header}.${changedPayload}.${signature}`;

  assert.equal(decodeJwt(tamperedToken).sub, 'administrador-demo');
  console.log('Decodificado: payload alterado legible');

  await esperarRechazo('payload alterado', tamperedToken);
  await esperarRechazo('expirado', await emitirToken({ ...claims, exp: now - 60 }));
  await esperarRechazo('otro issuer', await emitirToken({ ...claims, iss: 'https://otro.example' }));
  await esperarRechazo('otra audience', await emitirToken({ ...claims, aud: 'otra-api' }));

  const { exp, ...withoutExpiration } = claims;

  await esperarRechazo('sin exp', await emitirToken(withoutExpiration));
  await esperarRechazo('nbf futuro', await emitirToken({ ...claims, nbf: now + 60 }));
  await esperarRechazo('demasiado antiguo', await emitirToken({ ...claims, iat: now - 600 }));
  await esperarRechazo('otro tipo', await emitirToken(claims, privateKey, { typ: 'otro+jwt' }));

  const otherKeys = await generateKeyPair(ALGORITHM);

  await esperarRechazo('clave ajena', await emitirToken(claims, otherKeys.privateKey));

  const otherAlgorithm = await generateKeyPair('RS256');
  const rsaToken = await emitirToken(claims, otherAlgorithm.privateKey, { alg: 'RS256' });

  await esperarRechazo('algoritmo fuera de la lista', rsaToken);

  const unsecuredHeader = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
  const unsecuredPayload = Buffer.from(JSON.stringify(claims)).toString('base64url');

  await esperarRechazo('sin firma', `${unsecuredHeader}.${unsecuredPayload}.`);
  console.log('Todas las comprobaciones pasaron');
} finally {
  await new Promise((resolve, reject) => {
    server.close((error) => (error ? reject(error) : resolve()));
    server.closeAllConnections();
  });
}

Ejecuta el archivo:

node jwt-lab.mjs

La ejecución comprobada con Node.js 22 y jose 6.2.14 produjo:

Aceptado: token válido
Decodificado: payload alterado legible
Rechazado: payload alterado
Rechazado: expirado
Rechazado: otro issuer
Rechazado: otra audience
Rechazado: sin exp
Rechazado: nbf futuro
Rechazado: demasiado antiguo
Rechazado: otro tipo
Rechazado: clave ajena
Rechazado: algoritmo fuera de la lista
Rechazado: sin firma
Todas las comprobaciones pasaron

La prueba con otro emisor y la de otro destinatario usan firmas válidas de nuestra clave. Su rechazo demuestra algo importante: una firma válida no basta para aceptar el token en cualquier contexto.

El servidor se cierra al finalizar. Para limpiar, elimina la carpeta jwt-lab que creaste para el ejercicio.

Llevar la validación a una API real

Conserva la función de validación y sustituye la preparación del emisor de pruebas por la configuración verificada de tu proveedor. En el arranque, una comprobación mínima para el endpoint es:

const jwksUrl = new URL(process.env.AUTH_JWKS_URL);

if (jwksUrl.protocol !== 'https:') {
  throw new Error('El endpoint JWKS debe utilizar HTTPS');
}

const trustedKeys = createRemoteJWKSet(jwksUrl, {
  timeoutDuration: 2000,
});

Este bloque sustituye la creación del resolver local; no se añade al mismo archivo del laboratorio. AUTH_JWKS_URL debe ser configuración administrada por el servidor, nunca un valor proporcionado por la petición. HTTPS protege el transporte, pero no convierte en confiable cualquier dominio.

Configura también ISSUER, AUDIENCE, TOKEN_TYPE y el algoritmo según el contrato del emisor. Si el proveedor usa otro perfil, adapta sus reglas completas: no elimines comprobaciones solo para que un token de ejemplo pase.

Después de validar, resuelve la identidad por la pareja emisor y sujeto y comprueba sus permisos sobre el recurso solicitado. Nuestro ejemplo no implementa autorización, cuentas suspendidas ni revocación de sesiones.

Rotación, caché y reutilización

Durante una rotación planificada, coordina la publicación de claves nuevas y la conservación de las anteriores con la vida de los tokens y las cachés de los verificadores. Quitar una clave del servidor JWKS no implica que desaparezca inmediatamente de todas las cachés. En un incidente de clave comprometida, necesitas un procedimiento de revocación y renovación de cachés acorde con tu arquitectura. El resolver remoto tiene parámetros explícitos para caché, espera entre descargas y timeout. Opciones de JWKS.

La validación del ejemplo tampoco impide que un token válido robado se vuelva a presentar antes de caducar. Tener un jti no implementa por sí solo un registro de tokens revocados o usados. Decide si tu sistema necesita ese estado y qué alcance tendrá, en vez de asumir que la firma resuelve el ciclo de vida de una sesión.

Errores que conviene detectar en una revisión

  • Confiar en decodeJwt(): interpretar role, sub o cualquier otro campo no prueba su autenticidad.
  • Aceptar el algoritmo elegido por la petición: configura una lista permitida y claves del tipo esperado.
  • Comprobar solo la firma: una API también necesita verificar emisor, audiencia, tiempos y su perfil de claims.
  • Confundir clases de token: un token de identidad no sustituye automáticamente un token de acceso para una API; define reglas de aceptación distintas.
  • Usar contraseñas humanas como secretos HMAC: la clave necesita suficiente entropía.
  • Pegar tokens reales en depuradores públicos o logs: usa datos ficticios para inspección y evita exponer credenciales de acceso.

La restricción de algoritmos, la entropía de claves y la separación de tipos forman parte de las buenas prácticas de RFC 8725. Sus requisitos técnicos se aplican en el alcance descrito por el documento; no son, por sí mismos, obligaciones legales. RFC 8725, secciones 3.1, 3.5 y 3.12.

La confianza empieza después de las comprobaciones

Un JSON legible puede contener cualquier afirmación. El trabajo del servidor es decidir qué emisores y claves acepta, verificar la protección criptográfica y comprobar que el token corresponde a esta API, a este momento y a este propósito.

Solo entonces tiene sentido usar la identidad resultante para evaluar permisos. Esa separación mantiene una regla sencilla en el código: los datos decodificados sirven para inspeccionar; los datos validados pueden entrar en las decisiones de la aplicación.

Referencias

#JWT#JWS#JWE#JWKS#jose#Node.js#validación de tokens#ciberseguridad

Artículos relacionados