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:
| Paso | Comprobación | Quién suele hacerla |
|---|---|---|
| 1 | Descifrar, si se registró el cifrado | la biblioteca |
| 2 | iss es exactamente igual al emisor obtenido en el descubrimiento | la biblioteca, si le pasas el emisor |
| 3 | aud contiene tu client_id y ninguna audiencia en la que no confíes | la biblioteca hace la primera mitad; la segunda suele quedar en tus manos |
| 4, 5 | azp, si está presente, se valida (puede incluir: que sea igual a tu client_id) | depende |
| 6 | Firma, con las claves del emisor; opcional solo para un token recibido directamente del endpoint de token a través de TLS | la biblioteca |
| 7 | alg es RS256 o el id_token_signed_response_alg registrado | la biblioteca, si lo fijas |
| 8 | Para HS256/384/512, la clave es tu client_secret | la biblioteca |
| 9 | La hora actual es anterior a exp | la biblioteca |
| 10 | iat no está demasiado lejos del momento actual | tú |
| 11 | nonce es igual al valor enviado en la solicitud | tú, o una biblioteca que reciba el nonce esperado |
| 12 | acr es aceptable, si se solicitó | tú |
| 13 | auth_time es lo bastante reciente, si se solicitó o si se envió max_age | tú |
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.
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:
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-applo firma el mismo proveedor e incluyeweb-appenaud. Una comprobación que solo pregunta «¿contieneaudmi 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: suazpesother-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_hashyc_hashvinculan 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, yc_hashes 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:
// 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}`);
}
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ó:
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)
}
}
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:
# 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')}")
== 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/authorizeson v1.0, conisshttps://sts.windows.net/{tid}/; los de/oauth2/v2.0/authorizeson v2.0, conisshttps://login.microsoftonline.com/{tid}/v2.0(referencia de claims del token de ID). El claim (declaración)verindica 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 valorrequestedAccessTokenVersionde 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 eltiddel token, comprobar quetides un GUID y queisses exactamente la plantilla con esetid, y comprobar la propiedadissuerque 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
tidpermitidos. Las cuentas personales de Microsoft tienen el inquilino fijo9188040d-6c67-4c5b-b112-36a304b66dad. - Identifica a los usuarios por
tidmásoidosub. El mismosuben dos inquilinos corresponde a dos usuarios distintos.
Google: dos formas de escribir el emisor y azp para Android
isseshttps://accounts.google.comoaccounts.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 conhttps://. Un validador estricto que toma el emisor del descubrimiento rechaza los tokens con la forma corta; acepta exactamente estas dos cadenas (la opciónissuerde jose admite un array) y nada más laxo.audes 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,audes el ID de cliente web del backend yazpes 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 deazp.hdrestringe los dominios de Workspace. Si solo pueden iniciar sesión los usuarios de determinados dominios de Google Workspace, comprueba el claimhd; 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 configurashttps://your-tenant.auth0.comsin 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 fijaalgorithms: ["RS256"]para que un cambio de configuración no pueda pasar tu verificador a HMAC sin que te des cuenta.
Errores frecuentes
- 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). - 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.
- No guardar el nonce. Generar un nonce y no compararlo nunca equivale a no enviarlo.
- Identificar a los usuarios por
emailal iniciar sesión. Solo la parejaissysubes 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. - Incrustar las claves en el código. Obtenlas de
jwks_uriy sigue sus rotaciones; consulta rotación de claves JWKS sin interrupciones.
Fuentes
- OpenID Connect Core 1.0, con el conjunto de erratas 2 incorporado: §2 (token de ID), §3.1.3.7, §3.1.3.8, §3.3.2.10, §3.3.2.11, §5.7
- OpenID Connect Core 1.0, con el conjunto de erratas 1 incorporado, para la regla anterior sobre
azp - RFC 9207: identificación del emisor del servidor de autorización de OAuth 2.0
- Microsoft: referencia de claims del token de ID, tokens de acceso y validación del emisor
- Google: OpenID Connect
- Auth0: validar tokens de ID, algoritmos de firma
- Documentación de openid-client y de go-oidc