Resposta curta. Em todo token de ID do OpenID Connect: verifique a assinatura com uma chave do jwks_uri do provedor e somente com o algoritmo que você registrou (RS256 por padrão); exija que iss seja exatamente igual ao issuer do documento de descoberta; exija que aud contenha seu client_id e nada em que você não confie; se azp estiver presente, ele precisa ser um dos seus próprios clientes; exija exp no futuro; e compare nonce com o valor que você guardou para esta tentativa de login. Verifique at_hash e c_hash quando o token vier acompanhado de um token de acesso ou de um código. Depois, identifique o usuário pelo par iss e sub, nunca pelo email.
Os treze passos
O OpenID Connect Core 1.0 §3.1.3.7, no texto atual, que incorpora a errata set 2 (dezembro de 2023), lista as verificações que um cliente faz em um token de ID recebido do endpoint de token. Em resumo:
| Passo | Verificação | Quem costuma fazer |
|---|---|---|
| 1 | Descriptografar, se a criptografia foi registrada | biblioteca |
| 2 | iss é exatamente igual ao emissor do documento de descoberta | biblioteca, se você passar o emissor |
| 3 | aud contém seu client_id e nenhum público-alvo em que você não confia | a biblioteca faz a primeira metade; a segunda costuma ficar com você |
| 4, 5 | azp, se presente, é validado (pode incluir: ser igual ao seu client_id) | varia |
| 6 | Assinatura, com as chaves do emissor; opcional apenas para um token recebido diretamente do endpoint de token por TLS | biblioteca |
| 7 | alg é RS256 ou o id_token_signed_response_alg que você registrou | biblioteca, se você o fixar |
| 8 | Para HS256/384/512, a chave é seu client_secret | biblioteca |
| 9 | A hora atual é anterior a exp | biblioteca |
| 10 | iat não está longe demais do momento atual | você |
| 11 | nonce é igual ao valor enviado na requisição | você, ou uma biblioteca que receba o nonce esperado |
| 12 | acr é aceitável, se foi solicitado | você |
| 13 | auth_time é recente o bastante, se foi solicitado ou se max_age foi enviado | você |
Duas verificações relacionadas ficam em outras seções: at_hash vincula o token de acesso ao token de ID (§3.1.3.8, opcional no fluxo de código), e c_hash vincula o código de autorização a um token de ID retornado pelo endpoint de autorização no fluxo híbrido (§3.3.2.10). Os dois são a metade esquerda do hash do token ou do código, codificada em base64url, calculada com a função de hash do alg do token de ID: para RS256, os primeiros 128 bits do SHA-256.
Um validador que implementa os passos
O validador abaixo usa o jose para o que uma biblioteca de JWT faz bem (assinatura, alg, iss, o teste de inclusão em aud e exp) e acrescenta os passos que uma biblioteca de JWT não tem como conhecer, porque dependem da tentativa de login ou do registro do seu 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 é createRemoteJWKSet(new URL(discovery.jwks_uri)), criado uma vez por emissor. O passo 1 não aparece porque o laboratório não criptografa tokens de ID, e os passos 8 e 12 também não, porque ele usa RS256 e não solicita acr.
Teste com tokens reais
Para testar o validador com tokens de um provedor real, e não com tokens montados à mão, o laboratório rodou o Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1, modo de desenvolvimento) com um realm que tem um usuário e dois clientes confidenciais: web-app, a parte confiável (relying party) que está sendo protegida, e other-app, outra aplicação do mesmo provedor, configurada com um audience mapper para que os tokens de ID dela também listem web-app. Um pequeno script fez o papel do navegador: abriu a URL de autorização com state, nonce e desafio PKCE novos, enviou o formulário de login, pegou o código no redirecionamento e o trocou no endpoint de token. Cada token de ID passou então 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
O que cada rejeição impede:
- Um token de ID emitido para outra aplicação. O token de
other-appé assinado pelo mesmo provedor e listaweb-appemaud. Uma verificação que só pergunta “audcontém meu ID do cliente?”, que é o que a maioria das bibliotecas de JWT faz, o aceitou. O passo 3 manda rejeitar públicos-alvo em que você não confia, e o passo 5 também o teria barrado: oazpdele éother-app. Sem nenhum dos dois, qualquer aplicação registrada no mesmo provedor, inclusive uma que um atacante registre, consegue obter tokens que autenticam usuários na sua. - Um token de outra tentativa de login. A verificação do nonce rejeitou um token de ID válido, não expirado e assinado corretamente, porque ele pertencia a outra requisição de autorização. É isso que impede um atacante de injetar no login de uma vítima um token ou código obtido na própria sessão dele (passo 11), e é por isso que o nonce precisa ser guardado por tentativa de login, normalmente na mesma sessão do lado do servidor ou no mesmo cookie criptografado que o
state. - Artefatos trocados.
at_hashec_hashvinculam o token de acesso e o código ao token de ID. No fluxo híbrido, o token de ID do front channel passa pelo navegador, e é oc_hashque prova que o código que vem junto com ele é o mesmo que o provedor emitiu com ele.
Repare no parâmetro iss nos dois callbacks. O Keycloak o adiciona à resposta de autorização (RFC 9207), o que permite a um cliente que fala com vários provedores detectar uma resposta do provedor errado antes de resgatar o código.
O que as bibliotecas fazem por você
Uma biblioteca cliente de OpenID Connect certificada executa a maioria desses passos se você lhe passar os valores de que ela precisa. O openid-client é uma delas; quando recebe o nonce esperado, ele o verifica:
// 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 é o script do laboratório que faz as vezes do navegador do usuário; em uma aplicação, a URL de autorização é um redirecionamento e location é a URL da requisição de callback. A camada inferior do openid-client, o oauth4webapi 3.8.8, também aplica a regra da errata set 1 para azp: quando aud tem mais de um valor, azp precisa estar presente e ser igual ao ID do cliente.
O go-oidc v3.21.0 é um verificador, e não um cliente completo, e a documentação dele diz com todas as letras que “Verify does NOT do nonce validation, which is the caller's responsibility”. O script que conduzia o laboratório gravou em um arquivo dois tokens novos do mesmo realm, o nonce que tinha enviado e um nonce e um token de acesso de outro login, e este programa os verificou:
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 aceitou o primeiro token sem conhecer nonce nenhum; a comparação das duas linhas seguintes fica por conta de quem chama. Ele também aceitou o token emitido para other-app, porque a verificação de público-alvo dele se limita a testar a inclusão e não olha o azp. Com o go-oidc, compare idToken.Nonce com o valor que você guardou, chame idToken.VerifyAccessToken quando usar o token de acesso e verifique você mesmo a lista de públicos-alvo e o azp se seu provedor puder emitir tokens com vários públicos-alvo. Os erros que o go-oidc de fato gera estão no catálogo, por exemplo expected audience e issuer did not match.
Diferenças entre provedores que quebram validadores
O que os documentos de descoberta de quatro provedores informavam em 20 de outubro 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: duas versões de token e um emissor-modelo
- A versão acompanha o endpoint. Tokens de ID de
/oauth2/authorizesão v1.0, comisshttps://sts.windows.net/{tid}/; os de/oauth2/v2.0/authorizesão v2.0, comisshttps://login.microsoftonline.com/{tid}/v2.0(referência de claims do token de ID). A claimverindica qual é a versão. Um validador configurado com um formato de emissor rejeita o outro, e a mensagem do .NET é IDX10205: Issuer validation failed. Para tokens de acesso, quem escolhe a versão é orequestedAccessTokenVersionda API no manifesto do aplicativo, e não o endpoint. {tenantid}não é um valor. Os documentos de descoberta multilocatário (multi-tenant) acima retornam um emissor com o placeholder{tenantid}literal. Uma comparação exata com essa string nunca corresponde. A orientação da Microsoft sobre tokens de acesso manda substituí-lo pelotiddo token, verificar setidé um GUID e seissé exatamente o modelo com essetid, e verificar a propriedadeissuerque o Entra anexa a cada chave do JWKS dele.- Restrinja os locatários você mesmo. Uma configuração multilocatário aceita usuários de todos os locatários do Entra, inclusive os que um atacante cria. Se sua aplicação atende organizações específicas, mantenha uma lista de permissões de valores de
tid. Contas pessoais da Microsoft têm o locatário fixo9188040d-6c67-4c5b-b112-36a304b66dad. - Identifique usuários por
tidmaisoidousub. O mesmosubem dois locatários corresponde a dois usuários diferentes.
Google: duas grafias do emissor e o azp no Android
isséhttps://accounts.google.comouaccounts.google.com. A documentação de OpenID Connect do Google lista as duas formas como válidas, enquanto o documento de descoberta traz só a forma comhttps://. Um validador estrito que pega o emissor do documento de descoberta rejeita tokens com a forma curta; aceite exatamente essas duas strings (a opçãoissuerdo jose aceita um array) e nada mais permissivo.audé o ID do seu cliente web mesmo quando quem pediu foi um app Android. Quando um app Android solicita um token de ID para o backend dele,audé o ID do cliente web do backend eazpé o ID do cliente do app Android. Coloque os IDs dos seus clientes Android na lista de apresentadores confiáveis; não abra mão da verificação deazp.hdrestringe domínios do Workspace. Se só usuários de determinados domínios do Google Workspace puderem entrar, verifique a claimhd; a documentação do Google diz que a ausência dela significa que a conta não pertence a um domínio hospedado pelo Google. O domínio do e-mail, sozinho, não substitui essa verificação.
Auth0: uma barra no final e HS256
- O emissor termina com uma barra:
https://samples.auth0.com/. Configurarhttps://your-tenant.auth0.comsem ela faz todos os tokens falharem; a versão desse erro no go-oidc é issuer did not match, cujo exemplo é justamente uma barra final ausente. - Tokens de ID podem ser HS256. O documento de descoberta lista
HS256, e uma aplicação configurada para ele recebe tokens de ID assinados com o client secret dela (passo 8). Isso funciona para um cliente confidencial do lado do servidor, mas significa que o segredo é uma chave de assinatura, e qualquer serviço que o conheça consegue emitir tokens de ID. A própria página de algoritmos de assinatura da Auth0 recomenda RS256. Escolha-o nas configurações da aplicação e fixealgorithms: ["RS256"]para que uma mudança de configuração não consiga trocar silenciosamente seu verificador para HMAC.
Erros comuns
- Validar um token de ID como se fosse um token de acesso, ou o contrário. Um token de ID diz ao seu cliente quem entrou; ele não é uma credencial para chamar sua API. Convém que as APIs aceitem tokens de acesso com o próprio público-alvo delas (o artigo erros na verificação de JWT trata de
typ: at+jwt). - Pular a assinatura porque “veio por TLS”. O passo 6 só permite isso para um token que seu servidor recebeu diretamente do endpoint de token. Um token de ID que um app mobile ou um navegador envia ao seu backend veio do cliente, não do provedor, e precisa ser verificado por completo.
- Não guardar o nonce. Gerar um nonce e nunca compará-lo é o mesmo que não enviar nenhum.
- Fazer login de usuários pelo
email. Sóissmaissubé estável e único (§5.7). Associar contas pelo e-mail permite que qualquer pessoa capaz de obter um endereço não verificado em qualquer provedor que você aceite tome o controle da conta que tem esse endereço. - Fixar chaves no código. Busque-as no
jwks_urie acompanhe as rotações; veja rotação de chaves JWKS sem interrupção.
Fontes
- OpenID Connect Core 1.0, com a errata set 2: §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, com a errata set 1, para a regra anterior do
azp - RFC 9207: identificação do emissor do servidor de autorização OAuth 2.0
- Microsoft: referência de claims do token de ID, tokens de acesso e validação do emissor
- Google: OpenID Connect
- Auth0: validar tokens de ID, algoritmos de assinatura
- Documentação do openid-client e do go-oidc