Коротко. Для каждого ID-токена OpenID Connect проверяйте подпись ключом из jwks_uri провайдера и только тем алгоритмом, который вы зарегистрировали (по умолчанию RS256); требуйте, чтобы iss в точности совпадал с issuer из документа обнаружения (discovery document); требуйте, чтобы aud содержал ваш client_id и ничего, чему вы не доверяете; если присутствует azp, он должен указывать на один из ваших собственных клиентов; требуйте, чтобы exp был в будущем; сравнивайте nonce со значением, которое вы сохранили для этой попытки входа. Проверяйте at_hash и c_hash, если вместе с токеном пришёл токен доступа или код. Затем идентифицируйте пользователя по паре iss и sub и никогда не по email.
Тринадцать шагов
OpenID Connect Core 1.0, § 3.1.3.7 в действующей редакции, которая включает исправления errata set 2 (декабрь 2023 года), перечисляет проверки, которые клиент выполняет над ID-токеном, полученным от эндпоинта токенов. Вкратце:
| Шаг | Проверка | Кто её обычно выполняет |
|---|---|---|
| 1 | Расшифровка, если при регистрации выбрано шифрование | библиотека |
| 2 | iss в точности равен издателю из документа обнаружения | библиотека, если передать ей издателя |
| 3 | aud содержит ваш client_id и ни одной аудитории, которой вы не доверяете | первую половину выполняет библиотека, вторая часто остаётся за вами |
| 4, 5 | azp, если присутствует, проверен (проверка может включать равенство вашему client_id) | по-разному |
| 6 | Подпись, ключами издателя; необязательна только для токена, полученного напрямую от эндпоинта токенов по TLS | библиотека |
| 7 | alg — RS256 или зарегистрированный вами id_token_signed_response_alg | библиотека, если его зафиксировать |
| 8 | Для HS256/384/512 ключом служит ваш client_secret | библиотека |
| 9 | Текущее время раньше exp | библиотека |
| 10 | iat не слишком далеко от текущего момента | вы |
| 11 | nonce равен значению, отправленному в запросе | вы или библиотека, которой передают ожидаемый nonce |
| 12 | acr приемлем, если его запрашивали | вы |
| 13 | auth_time достаточно свежий, если его запрашивали или был отправлен max_age | вы |
Ещё две связанные проверки описаны в других разделах: at_hash привязывает токен доступа к ID-токену (§ 3.1.3.8, в потоке с кодом авторизации эта проверка необязательна), а c_hash привязывает код авторизации к ID-токену, который в гибридном потоке возвращает эндпоинт авторизации (§ 3.3.2.10). Оба значения — закодированная в base64url левая половина хеша токена или кода, причём хеш-функцию определяет alg ID-токена: для RS256 это первые 128 бит SHA-256.
Валидатор, который их реализует
Валидатор ниже поручает jose то, с чем библиотека JWT справляется хорошо (подпись, alg, iss, вхождение в aud, exp), и добавляет шаги, о которых библиотека JWT знать не может, потому что они зависят от попытки входа или от регистрации вашего клиента.
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)), который создаётся один раз для каждого издателя. Шага 1 здесь нет, потому что на стенде ID-токены не шифруются, а шагов 8 и 12 — потому что стенд использует RS256 и не запрашивает acr.
Проверка на реальных токенах
Чтобы проверить валидатор на токенах настоящего провайдера, а не на собранных вручную, на стенде запустили Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1, режим разработки) с realm, в котором были один пользователь и два конфиденциальных клиента: web-app — защищаемая доверяющая сторона (relying party, RP), и other-app — другое приложение того же провайдера, для которого настроен маппер аудитории, чтобы в его ID-токенах тоже был указан web-app. Роль браузера играл небольшой тестовый скрипт: он открывал URL авторизации с новыми state, nonce и PKCE challenge, отправлял форму входа, забирал код из перенаправления и обменивал его на эндпоинте токенов. Затем каждый ID-токен проходил через validateIdToken от имени 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
От чего защищает каждое из этих отклонений:
- ID-токен, выпущенный для другого приложения. Токен
other-appподписан тем же провайдером, и в егоaudуказанweb-app. Проверка, которая лишь выясняет, «содержит лиaudмой идентификатор клиента» (так поступает большинство библиотек JWT), этот токен приняла. Шаг 3 требует отклонять аудитории, которым вы не доверяете, и шаг 5 тоже поймал бы этот токен: егоazp—other-app. Без этих проверок любое приложение, зарегистрированное у того же провайдера, в том числе зарегистрированное злоумышленником, может получить токены, с которыми в ваше приложение можно войти от имени пользователей. - Токен из другой попытки входа. Проверка nonce отклонила действительный, неистёкший и правильно подписанный ID-токен, потому что он относился к другому запросу авторизации. Именно эта проверка не даёт злоумышленнику подставить в процесс входа жертвы токен или код, полученный в его собственной сессии (шаг 11), и именно поэтому nonce должен храниться отдельно для каждой попытки входа — обычно в той же серверной сессии или в том же зашифрованном файле cookie, что и
state. - Перепутанные артефакты.
at_hashиc_hashпривязывают токен доступа и код к ID-токену. В гибридном потоке ID-токен из фронт-канала (front channel) проходит через браузер, и именноc_hashдоказывает, что пришедший рядом с ним код — тот самый, который провайдер выдал вместе с этим токеном.
Обратите внимание на параметр iss в обоих обратных вызовах. Keycloak добавляет его в ответ авторизации (RFC 9207), и это позволяет клиенту, который работает с несколькими провайдерами, распознать ответ не от того провайдера ещё до обмена кода.
Что библиотеки делают за вас
Сертифицированная клиентская библиотека OpenID Connect выполняет большинство этих шагов, если передать ей нужные значения. openid-client — одна из таких библиотек; если передать ей ожидаемый nonce, она его проверяет:
// 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 — тестовый скрипт стенда, который заменяет браузер пользователя; в настоящем приложении URL авторизации — это перенаправление, а location — URL запроса обратного вызова. Нижний уровень openid-client, oauth4webapi 3.8.8, к тому же применяет правило для azp из errata set 1: если в aud больше одного значения, azp должен присутствовать и быть равен идентификатору клиента.
go-oidc v3.21.0 — библиотека проверки, а не полноценный клиент, и в её документации прямо сказано: «Verify does NOT do nonce validation, which is the caller's responsibility.» Управляющий скрипт стенда записал в файл два свежих токена из того же realm, отправленный им nonce, а также nonce и токен доступа из другого входа, и эта программа их проверила:
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 принял первый токен, не зная никакого nonce; сравнение в следующих двух строках выполняет вызывающий код. Он принял и токен, выпущенный для other-app, потому что при проверке аудитории смотрит только, есть ли среди аудиторий нужный клиент, а azp не учитывает. С go-oidc сравнивайте idToken.Nonce с сохранённым значением, вызывайте idToken.VerifyAccessToken, если используете токен доступа, и сами проверяйте список аудиторий и azp, если ваш провайдер может выпускать токены с несколькими аудиториями. Ошибки, которые go-oidc всё же выдаёт, есть в каталоге, например expected audience и issuer did not match.
Различия провайдеров, которые ломают валидаторы
Что было указано в документах обнаружения четырёх провайдеров 20 октября 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: две версии токенов и издатель-шаблон
- Версия зависит от эндпоинта. ID-токены от
/oauth2/authorizeимеют версию v1.0 иisshttps://sts.windows.net/{tid}/, а от/oauth2/v2.0/authorize— версию v2.0 иisshttps://login.microsoftonline.com/{tid}/v2.0(справочник по утверждениям ID-токена). Какая это версия, показывает утверждение (claim)ver. Валидатор, в настройках которого задан один формат издателя, отклоняет токены с другим, и в .NET это ошибка IDX10205: Issuer validation failed. У токенов доступа версию определяет не эндпоинт, а параметрrequestedAccessTokenVersionв манифесте приложения API. {tenantid}— не значение. Мультитенантные документы обнаружения выше возвращают издателя, в котором буквально стоит заполнитель{tenantid}. Точное сравнение с этой строкой никогда не даст совпадения. Руководство Microsoft по токенам доступа предписывает подставитьtidиз токена, проверить, чтоtid— это GUID и чтоissв точности совпадает с шаблоном, в который подставлен этотtid, а также проверить свойствоissuer, которое Entra добавляет к каждому ключу в своём JWKS.- Ограничивайте тенанты сами. Мультитенантная конфигурация принимает пользователей из любого тенанта Entra, в том числе из тех, которые создал злоумышленник. Если ваше приложение обслуживает определённые организации, ведите список разрешённых значений
tid. У личных учётных записей Microsoft фиксированный тенант9188040d-6c67-4c5b-b112-36a304b66dad. - Идентифицируйте пользователей по
tidвместе сoidилиsub. Один и тот жеsubв двух тенантах — это два разных пользователя.
Google: два написания издателя и azp для Android
iss— либоhttps://accounts.google.com, либоaccounts.google.com. Документация Google по OpenID Connect считает допустимыми оба варианта, а в документе обнаружения указана только форма сhttps://. Строгий валидатор, который берёт издателя из документа обнаружения, отклоняет токены с короткой формой; принимайте ровно эти две строки (параметрissuerв jose принимает массив) и не ослабляйте сравнение сверх этого.aud— идентификатор вашего веб-клиента, даже если токен запросило Android-приложение. Когда Android-приложение запрашивает ID-токен для своего бэкенда,aud— это идентификатор веб-клиента бэкенда, аazp— идентификатор клиента Android-приложения. Внесите идентификаторы своих Android-клиентов в список доверенных предъявителей (trusted presenters) и не отказывайтесь от проверкиazp.hdограничивает домены Workspace. Если входить могут только пользователи определённых доменов Google Workspace, проверяйте утверждениеhd; в документации Google сказано, что его отсутствие означает, что учётная запись не относится к домену, размещённому в Google. Домен в адресе электронной почты сам по себе эту проверку не заменяет.
Auth0: косая черта в конце и HS256
- Издатель заканчивается косой чертой:
https://samples.auth0.com/. Если указать в настройкахhttps://your-tenant.auth0.comбез неё, проверку не пройдёт ни один токен; в go-oidc эта ошибка выглядит как issuer did not match, и пример на её странице — как раз пропущенная косая черта в конце. - ID-токены могут быть подписаны HS256. В документе обнаружения указан
HS256, и приложение, для которого выбран этот алгоритм, получает ID-токены, подписанные его секретом клиента (шаг 8). Для конфиденциального клиента на стороне сервера это работает, но секрет при этом становится ключом подписи, и любой сервис, который его знает, может выпускать ID-токены. Сам Auth0 на странице об алгоритмах подписи рекомендует RS256. Выберите его в настройках приложения и зафиксируйтеalgorithms: ["RS256"], чтобы изменение конфигурации не могло незаметно переключить ваш код проверки на HMAC.
Частые ошибки
- Проверка ID-токена так, будто это токен доступа, или наоборот. ID-токен сообщает вашему клиенту, кто вошёл в систему; это не учётные данные для вызова вашего API. API следует принимать токены доступа со своей собственной аудиторией (
typ: at+jwtразобран в статье Ошибки проверки JWT). - Пропуск проверки подписи, потому что «токен пришёл по TLS». Шаг 6 допускает это только для токена, который ваш сервер получил напрямую от эндпоинта токенов. ID-токен, который мобильное приложение или браузер отправляет на ваш бэкенд, пришёл от клиента, а не от провайдера, и должен проверяться полностью.
- Несохранённый nonce. Сгенерировать nonce и ни разу его не сравнить — то же самое, что не отправлять его вовсе.
- Вход пользователей по
email. Стабильна и уникальна только параissиsub(§ 5.7). Если сопоставлять пользователей по email, любой, кто может получить неподтверждённый адрес у любого из принимаемых вами провайдеров, сможет завладеть учётной записью с этим адресом. - Ключи, зашитые в код. Загружайте их из
jwks_uriи отслеживайте ротацию ключей; подробнее в статье Ротация ключей JWKS без простоя.
Источники
- OpenID Connect Core 1.0 с исправлениями errata set 2: § 2 (ID-токен), § 3.1.3.7, § 3.1.3.8, § 3.3.2.10, § 3.3.2.11, § 5.7
- OpenID Connect Core 1.0 с исправлениями errata set 1 — прежнее правило для
azp - RFC 9207: идентификация издателя сервера авторизации в OAuth 2.0
- Microsoft: справочник по утверждениям ID-токена, токены доступа и проверка издателя
- Google: OpenID Connect
- Auth0: проверка ID-токенов, алгоритмы подписи
- Документация openid-client и go-oidc