Saltar al contenido
ecdsa.com
← Todos los artículos

Validación del token de ID de OIDC, paso a paso

Un token de ID es un JWT con reglas propias. Trece pasos numerados de la especificación deciden si de verdad autenticas a un usuario o solo analizas un blob firmado. Aquí tienes cada paso, un validador que los implementa y lo que detectó al probarlo con tokens reales de un realm de Keycloak.

Respuesta breve. Para cada token de ID de OpenID Connect: verifica la firma con una clave del jwks_uri del proveedor y solo con el algoritmo registrado (RS256 por defecto); exige que iss sea exactamente igual al issuer del documento de descubrimiento; exige que aud contenga tu client_id y nada en lo que no confíes; si azp está presente, debe ser uno de tus propios clientes; exige que exp esté en el futuro, y compara nonce con el valor guardado para este intento de inicio de sesión. Comprueba at_hash y c_hash cuando el token llega junto con un token de acceso o un código. Después, identifica al usuario por el par iss y sub, nunca por email.

Los trece pasos

OpenID Connect Core 1.0 §3.1.3.7, en su texto actual, que incorpora el conjunto de erratas 2 (diciembre de 2023), enumera las comprobaciones que un cliente realiza sobre un token de ID recibido del endpoint de token. En resumen:

PasoComprobaciónQuién suele hacerla
1Descifrar, si se registró el cifradola biblioteca
2iss es exactamente igual al emisor obtenido en el descubrimientola biblioteca, si le pasas el emisor
3aud contiene tu client_id y ninguna audiencia en la que no confíesla biblioteca hace la primera mitad; la segunda suele quedar en tus manos
4, 5azp, si está presente, se valida (puede incluir: que sea igual a tu client_id)depende
6Firma, con las claves del emisor; opcional solo para un token recibido directamente del endpoint de token a través de TLSla biblioteca
7alg es RS256 o el id_token_signed_response_alg registradola biblioteca, si lo fijas
8Para HS256/384/512, la clave es tu client_secretla biblioteca
9La hora actual es anterior a expla biblioteca
10iat no está demasiado lejos del momento actualtú
11nonce es igual al valor enviado en la solicitudtú, o una biblioteca que reciba el nonce esperado
12acr es aceptable, si se solicitótú
13auth_time es lo bastante reciente, si se solicitó o si se envió max_agetú

Hay dos comprobaciones relacionadas en otras secciones: at_hash vincula el token de acceso al token de ID (§3.1.3.8, opcional en el flujo de código), y c_hash vincula el código de autorización a un token de ID devuelto desde el endpoint de autorización en el flujo híbrido (§3.3.2.10). Ambos son la mitad izquierda del hash del token o del código, codificada en base64url, calculado con el hash del alg del token de ID: para RS256, los primeros 128 bits de SHA-256.

Un validador que los implementa

El validador de abajo usa jose para lo que una biblioteca de JWT hace bien (la firma, alg, iss, que aud incluya el cliente, exp) y añade los pasos de los que una biblioteca de JWT no puede saber nada, porque dependen del intento de inicio de sesión o del registro de tu cliente.

validate-id-token.mjs (jose 6.2.13, Node.js 24.22)
import { createHash } from "node:crypto";
import { jwtVerify } from "jose";

// at_hash and c_hash: the left half of the hash of the ASCII value, base64url-encoded,
// with the hash function of the ID token's alg (OIDC Core 3.1.3.6 and 3.3.2.11).
const HASH = { RS256: "sha256", PS256: "sha256", ES256: "sha256", RS384: "sha384", PS384: "sha384", ES384: "sha384", RS512: "sha512", PS512: "sha512", ES512: "sha512" };
export function halfHash(value, alg) {
  const digest = createHash(HASH[alg]).update(value, "ascii").digest();
  return digest.subarray(0, digest.length / 2).toString("base64url");
}

/**
 * Validates an ID token as OpenID Connect Core 1.0 §3.1.3.7 describes.
 * rp:       { issuer, jwks, clientId, alg, trustedAudiences, trustedPresenters, maxIatAgeSeconds }
 * expected: { nonce, maxAge?, accessToken?, code? } from this login's stored state and the response
 */
export async function validateIdToken(idToken, rp, expected) {
  // Steps 2, 3 (aud contains client_id), 6, 7 and 9: issuer, audience, signature, alg, expiry.
  const { payload: claims, protectedHeader } = await jwtVerify(idToken, rp.jwks, {
    issuer: rp.issuer, // exact string from the discovery document
    audience: rp.clientId,
    algorithms: [rp.alg], // RS256 unless id_token_signed_response_alg says otherwise
    requiredClaims: ["iss", "sub", "aud", "exp", "iat"],
    clockTolerance: 30,
  });
  const now = Math.floor(Date.now() / 1000);
  const fail = (step, why) => {
    throw new Error(`step ${step}: ${why}`);
  };

  // Step 3, second half: reject audiences this client does not trust.
  const untrusted = [claims.aud].flat().filter((a) => a !== rp.clientId && !rp.trustedAudiences.includes(a));
  if (untrusted.length > 0) fail(3, `untrusted audience ${untrusted.join(", ")}`);
  // Steps 4-5: an azp, when present, must name a client of this application.
  if (claims.azp !== undefined && ![rp.clientId, ...rp.trustedPresenters].includes(claims.azp)) {
    fail(5, `azp is ${claims.azp}`);
  }
  // Step 10: iat bounds how long nonces must be remembered.
  if (now - claims.iat > rp.maxIatAgeSeconds) fail(10, `issued ${now - claims.iat} s ago`);
  // Step 11: the nonce stored with this login attempt, compared exactly.
  if (claims.nonce !== expected.nonce) fail(11, `nonce ${claims.nonce} does not match the stored value`);
  // Step 13: when max_age was sent, auth_time is required and bounded.
  if (expected.maxAge !== undefined) {
    if (typeof claims.auth_time !== "number") fail(13, "auth_time missing");
    if (now - claims.auth_time > expected.maxAge + 30) fail(13, "authentication too old");
  }
  // Hashes: tie the access token (at_hash) and the code (c_hash) to this ID token.
  if (expected.accessToken !== undefined && claims.at_hash !== undefined) {
    if (claims.at_hash !== halfHash(expected.accessToken, protectedHeader.alg)) fail("3.1.3.8", "at_hash mismatch");
  }
  if (expected.code !== undefined) {
    if (claims.c_hash !== halfHash(expected.code, protectedHeader.alg)) fail("3.3.2.10", "c_hash missing or mismatch");
  }
  return claims;
}

rp.jwks es createRemoteJWKSet(new URL(discovery.jwks_uri)), creado una vez por emisor. Falta el paso 1 porque el laboratorio no cifra los tokens de ID, y faltan los pasos 8 y 12 porque usa RS256 y no solicita acr.

Prueba con tokens reales

Para probar el validador con tokens de un proveedor real en lugar de tokens hechos a mano, el laboratorio ejecutó Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1, modo de desarrollo) con un realm que contenía un usuario y dos clientes confidenciales: web-app, la relying party (RP) que se quiere proteger —es decir, la parte que confía en la autenticación—, y other-app, otra aplicación del mismo proveedor, configurada con un mapper de audiencia para que sus tokens de ID también incluyan web-app. Un pequeño arnés de pruebas hacía de navegador: abría la URL de autorización con state, nonce y desafío PKCE nuevos, enviaba el formulario de inicio de sesión, tomaba el código de la redirección y lo canjeaba en el endpoint de token. Después, cada token de ID pasó por validateIdToken como web-app:

Salida: inicios de sesión reales en Keycloak 26.8.1, validados como web-app (node:24.22.0-bookworm-slim)
issuer http://127.0.0.1:8080/realms/demo
code flow callback parameters: state, session_state, iss, code
ID token header: alg RS256, typ JWT, kid present: true
ID token claims: exp, iat, auth_time, jti, iss, aud, sub, typ, azp, nonce, sid, at_hash, acr, email_verified, name, preferred_username, given_name, family_name, email
aud "web-app", azp web-app, at_hash present
code flow ID token                                   valid
hybrid callback parameters: state, session_state, iss, code, id_token
front-channel ID token: c_hash present, at_hash absent
hybrid front-channel ID token, with its code         valid
same ID token, code from the first login             rejected: step 3.3.2.10: c_hash missing or mismatch
other-app ID token: aud ["other-app","web-app"], azp other-app
other-app token, aud-contains check only             valid
other-app token, full validation                     rejected: step 3: untrusted audience other-app
first ID token, nonce of the second login            rejected: step 11: nonce lsamKJG6vC-xAIOzo1LIrw does not match the stored value
first ID token, access token of the second login     rejected: step 3.1.3.8: at_hash mismatch

Qué evita cada rechazo:

  • Un token de ID emitido para otra aplicación. El token de other-app lo firma el mismo proveedor e incluye web-app en aud. Una comprobación que solo pregunta «¿contiene aud mi ID de cliente?», que es lo que hacen la mayoría de las bibliotecas de JWT, lo aceptó. El paso 3 dice que hay que rechazar las audiencias en las que no confías, y el paso 5 también lo habría detectado: su azp es other-app. Sin ninguna de las dos comprobaciones, cualquier aplicación registrada en el mismo proveedor, incluida una que registre un atacante, puede obtener tokens con los que se inicia la sesión de los usuarios en la tuya.
  • Un token de otro intento de inicio de sesión. La comprobación del nonce rechazó un token de ID válido, no caducado y correctamente firmado porque pertenecía a otra solicitud de autorización. Eso es lo que impide que un atacante inyecte en el inicio de sesión de una víctima un token o un código obtenido en su propia sesión (paso 11), y por eso el nonce debe guardarse por cada intento de inicio de sesión, normalmente en la misma sesión del lado del servidor o en la misma cookie cifrada que state.
  • Artefactos intercambiados. at_hash y c_hash vinculan el token de acceso y el código al token de ID. En el flujo híbrido, el token de ID del canal frontal viaja por el navegador, y c_hash es lo que demuestra que el código que lo acompaña es el que el proveedor emitió con él.

Fíjate en el parámetro iss de los dos callbacks. Keycloak lo añade a la respuesta de autorización (RFC 9207), lo que permite a un cliente que trabaja con varios proveedores detectar una respuesta del proveedor equivocado antes de canjear el código.

Qué hacen las bibliotecas por ti

Una biblioteca cliente de OpenID Connect certificada realiza la mayoría de estos pasos si le das los valores que necesita. openid-client es una de ellas; si recibe el nonce esperado, lo comprueba:

openid-client-flow.mjs (openid-client 6.8.8)
// The library route: openid-client 6.8.8 runs discovery, the code exchange and the ID token checks.
import * as client from "openid-client";
import { browserLogin } from "./login.mjs";

const issuer = new URL("http://127.0.0.1:8080/realms/demo");
// allowInsecureRequests only because the lab issuer is plain http; never in production.
const config = await client.discovery(issuer, "web-app", process.env.WEB, undefined, {
  execute: [client.allowInsecureRequests],
});

async function signIn(expectedNonceOverride) {
  const codeVerifier = client.randomPKCECodeVerifier();
  const state = client.randomState();
  const nonce = client.randomNonce();
  const url = client.buildAuthorizationUrl(config, {
    redirect_uri: "http://127.0.0.1:3000/callback",
    scope: "openid email",
    code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
    code_challenge_method: "S256",
    state,
    nonce,
  });
  const { location } = await browserLogin(url, "alice", process.env.PW);
  const tokens = await client.authorizationCodeGrant(config, new URL(location), {
    pkceCodeVerifier: codeVerifier,
    expectedState: state,
    expectedNonce: expectedNonceOverride ?? nonce,
    idTokenExpected: true,
  });
  return tokens.claims();
}

const claims = await signIn();
console.log(`signed in: sub ${claims.sub.slice(0, 8)}..., email ${claims.email}, nonce checked`);
try {
  await signIn(client.randomNonce());
} catch (e) {
  console.log(`wrong expectedNonce: ${e.name}: ${e.message}; cause: ${e.cause?.message}`);
}
Salida
signed in: sub aa5e60df..., email alice@example.com, nonce checked
wrong expectedNonce: ClientError: unexpected JWT claim value encountered; cause: unexpected ID Token "nonce" claim value

browserLogin es el arnés de pruebas del laboratorio que hace las veces del navegador del usuario; en una aplicación, la URL de autorización es una redirección y location es la URL de la solicitud de callback. La capa inferior de openid-client, oauth4webapi 3.8.8, también aplica la regla del conjunto de erratas 1 para azp: cuando aud tiene más de un valor, azp debe estar presente y ser igual al ID de cliente.

go-oidc v3.21.0 es un verificador, no un cliente completo, y su documentación dice sin rodeos que «Verify NO valida el nonce; eso es responsabilidad de quien llama». El script de control del laboratorio escribió en un archivo dos tokens nuevos del mismo realm, el nonce que había enviado, y un nonce y un token de acceso de otro inicio de sesión, y este programa los comprobó:

main.go (go-oidc v3.21.0, Go 1.27.3)
package main

// What go-oidc's IDTokenVerifier checks on its own, against ID tokens from the lab Keycloak realm.

import (
	"context"
	"encoding/json"
	"fmt"
	"os"

	"github.com/coreos/go-oidc/v3/oidc"
)

func main() {
	var h struct {
		Issuer, IDToken, AccessToken, NonceSent, NonceOfAnotherLogin, OtherAppIDToken, OtherAccessToken string
	}
	data, _ := os.ReadFile("/node/handoff.json")
	if err := json.Unmarshal(data, &h); err != nil {
		panic(err)
	}
	ctx := context.Background()
	ctx = oidc.InsecureIssuerURLContext(ctx, h.Issuer) // only because the lab issuer is plain http
	provider, err := oidc.NewProvider(ctx, h.Issuer)
	if err != nil {
		panic(err)
	}
	verifier := provider.Verifier(&oidc.Config{ClientID: "web-app"})

	token, err := verifier.Verify(ctx, h.IDToken)
	fmt.Printf("%-44s err=%v\n", "Verify(web-app ID token)", err)
	fmt.Printf("%-44s %v\n", "  nonce equals the nonce we sent", token.Nonce == h.NonceSent)
	fmt.Printf("%-44s %v\n", "  nonce equals another login's nonce", token.Nonce == h.NonceOfAnotherLogin)
	fmt.Printf("%-44s err=%v\n", "  VerifyAccessToken(its access token)", token.VerifyAccessToken(h.AccessToken))
	fmt.Printf("%-44s err=%v\n", "  VerifyAccessToken(another access token)", token.VerifyAccessToken(h.OtherAccessToken))

	other, err := verifier.Verify(ctx, h.OtherAppIDToken)
	if err == nil {
		var c struct {
			Azp string `json:"azp"`
		}
		_ = other.Claims(&c)
		fmt.Printf("%-44s err=<nil>, aud=%v, azp=%s\n", "Verify(other-app ID token)", other.Audience, c.Azp)
	} else {
		fmt.Printf("%-44s err=%v\n", "Verify(other-app ID token)", err)
	}
}
Salida (golang:1.27.3-trixie)
Verify(web-app ID token)                     err=<nil>
  nonce equals the nonce we sent             true
  nonce equals another login's nonce         false
  VerifyAccessToken(its access token)        err=<nil>
  VerifyAccessToken(another access token)    err=access token hash does not match value in ID token
Verify(other-app ID token)                   err=<nil>, aud=[other-app web-app], azp=other-app

Verify aceptó el primer token sin conocer ningún nonce; la comparación de las dos líneas siguientes corre a cargo de quien llama. También aceptó el token emitido para other-app, porque su comprobación de audiencia solo mira que el cliente esté incluido y no examina azp. Con go-oidc, compara idToken.Nonce con el valor guardado, llama a idToken.VerifyAccessToken cuando uses el token de acceso y comprueba tú mismo la lista de audiencias y azp si tu proveedor puede emitir tokens con varias audiencias. Los errores que go-oidc sí genera están en el catálogo, por ejemplo expected audience e issuer did not match.

Diferencias entre proveedores que rompen los validadores

Esto es lo que decían los documentos de descubrimiento de cuatro proveedores el 20 de octubre de 2026:

discovery.py
# Prints issuer, jwks_uri and ID token signing algorithms from public OpenID Connect discovery documents.
import json
import urllib.request

URLS = [
    "https://accounts.google.com/.well-known/openid-configuration",
    "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
    "https://login.microsoftonline.com/common/.well-known/openid-configuration",
    "https://samples.auth0.com/.well-known/openid-configuration",
    "https://appleid.apple.com/.well-known/openid-configuration",
]
for url in URLS:
    with urllib.request.urlopen(url, timeout=20) as response:
        doc = json.load(response)
    print(f"== {url}")
    print(f"   issuer   {doc['issuer']}")
    print(f"   jwks_uri {doc['jwks_uri']}")
    print(f"   id_token_signing_alg_values_supported {doc.get('id_token_signing_alg_values_supported')}")
Salida (python:3.14.8-slim-trixie)
== https://accounts.google.com/.well-known/openid-configuration
   issuer   https://accounts.google.com
   jwks_uri https://www.googleapis.com/oauth2/v3/certs
   id_token_signing_alg_values_supported ['RS256']
== https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
   issuer   https://login.microsoftonline.com/{tenantid}/v2.0
   jwks_uri https://login.microsoftonline.com/common/discovery/v2.0/keys
   id_token_signing_alg_values_supported ['RS256']
== https://login.microsoftonline.com/common/.well-known/openid-configuration
   issuer   https://sts.windows.net/{tenantid}/
   jwks_uri https://login.microsoftonline.com/common/discovery/keys
   id_token_signing_alg_values_supported ['RS256']
== https://samples.auth0.com/.well-known/openid-configuration
   issuer   https://samples.auth0.com/
   jwks_uri https://samples.auth0.com/.well-known/jwks.json
   id_token_signing_alg_values_supported ['HS256', 'RS256', 'PS256']
== https://appleid.apple.com/.well-known/openid-configuration
   issuer   https://appleid.apple.com
   jwks_uri https://appleid.apple.com/auth/keys
   id_token_signing_alg_values_supported ['RS256']

Microsoft Entra ID: dos versiones de token y un emisor con plantilla

  • La versión depende del endpoint. Los tokens de ID de /oauth2/authorize son v1.0, con iss https://sts.windows.net/{tid}/; los de /oauth2/v2.0/authorize son v2.0, con iss https://login.microsoftonline.com/{tid}/v2.0 (referencia de claims del token de ID). El claim (declaración) ver indica cuál es. Un validador configurado con un formato de emisor rechaza el otro, y el mensaje de .NET es IDX10205: Issuer validation failed. En los tokens de acceso, la versión no la elige el endpoint, sino el valor requestedAccessTokenVersion de la API en el manifiesto de la aplicación.
  • {tenantid} no es un valor. Los documentos de descubrimiento multiinquilino de arriba devuelven un emisor con un marcador {tenantid} literal. Una comparación exacta con esa cadena nunca coincide. La guía sobre tokens de acceso de Microsoft indica que hay que sustituir el marcador por el tid del token, comprobar que tid es un GUID y que iss es exactamente la plantilla con ese tid, y comprobar la propiedad issuer que Entra añade a cada clave de su JWKS.
  • Restringe tú mismo los inquilinos. Una configuración multiinquilino acepta usuarios de cualquier inquilino de Entra, incluidos los que cree un atacante. Si tu aplicación da servicio a organizaciones concretas, mantén una lista de valores de tid permitidos. Las cuentas personales de Microsoft tienen el inquilino fijo 9188040d-6c67-4c5b-b112-36a304b66dad.
  • Identifica a los usuarios por tid más oid o sub. El mismo sub en dos inquilinos corresponde a dos usuarios distintos.

Google: dos formas de escribir el emisor y azp para Android

  • iss es https://accounts.google.com o accounts.google.com. La documentación de OpenID Connect de Google da ambos como válidos, mientras que el documento de descubrimiento solo incluye la forma con https://. Un validador estricto que toma el emisor del descubrimiento rechaza los tokens con la forma corta; acepta exactamente estas dos cadenas (la opción issuer de jose admite un array) y nada más laxo.
  • aud es tu ID de cliente web incluso cuando lo pidió una aplicación Android. Cuando una aplicación Android solicita un token de ID para su backend, aud es el ID de cliente web del backend y azp es el ID de cliente de la aplicación Android. Añade tus ID de cliente de Android a la lista de presentadores de confianza; no elimines la comprobación de azp.
  • hd restringe los dominios de Workspace. Si solo pueden iniciar sesión los usuarios de determinados dominios de Google Workspace, comprueba el claim hd; la documentación de Google dice que, si falta, la cuenta no pertenece a un dominio alojado en Google. El dominio del correo electrónico por sí solo no lo sustituye.

Auth0: una barra final y HS256

  • El emisor termina con una barra: https://samples.auth0.com/. Si configuras https://your-tenant.auth0.com sin ella, la validación falla con todos los tokens; en go-oidc, este error es issuer did not match, cuyo ejemplo es precisamente una barra final que falta.
  • Los tokens de ID pueden ser HS256. El documento de descubrimiento incluye HS256, y una aplicación configurada con ese algoritmo recibe tokens de ID firmados con su secreto de cliente (paso 8). Eso funciona para un cliente confidencial del lado del servidor, pero significa que el secreto es una clave de firma y que cualquier servicio que lo conozca puede emitir tokens de ID. La propia página de algoritmos de firma de Auth0 recomienda RS256. Elígelo en la configuración de la aplicación y fija algorithms: ["RS256"] para que un cambio de configuración no pueda pasar tu verificador a HMAC sin que te des cuenta.

Errores frecuentes

  1. Validar un token de ID como si fuera un token de acceso, o al revés. Un token de ID le dice a tu cliente quién inició sesión; no es una credencial para llamar a tu API. Las API deberían aceptar tokens de acceso con su propia audiencia (errores al verificar JWT trata typ: at+jwt).
  2. Omitir la verificación de la firma porque «llegó por TLS». El paso 6 solo lo permite para un token que tu servidor recibió directamente del endpoint de token. Un token de ID que una aplicación móvil o un navegador envía a tu backend viene del cliente, no del proveedor, y debe verificarse por completo.
  3. No guardar el nonce. Generar un nonce y no compararlo nunca equivale a no enviarlo.
  4. Identificar a los usuarios por email al iniciar sesión. Solo la pareja iss y sub es estable y única (§5.7). Buscar la cuenta por el correo electrónico permite que cualquiera que consiga una dirección no verificada en cualquier proveedor que aceptes se apodere de la cuenta con esa dirección.
  5. Incrustar las claves en el código. Obtenlas de jwks_uri y sigue sus rotaciones; consulta rotación de claves JWKS sin interrupciones.

Fuentes