Работа с несколькими JWKS-источниками возникает в системах, где токены выпускаются разными провайдерами идентификации, либо когда один провайдер использует несколько наборов ключей для разных окружений, регионов или клиентов. В таких архитектурах проверка JWT перестаёт быть линейной задачей «один issuer — один JWKS URL» и превращается в задачу маршрутизации и приоритизации источников ключей.
JWKS (JSON Web Key Set) представляет собой набор публичных ключей, используемых для проверки подписи JWT. Обычно он доступен по URL вида:
https://auth.example.com/.well-known/jwks.json
В реальных системах появляется несколько таких источников:
Каждый JWT содержит минимум два критичных поля:
iss (issuer) — кто выпустил токенkid (key id) — идентификатор ключа подписиИменно их комбинация определяет, из какого JWKS нужно брать ключ.
Библиотека jose предоставляет готовую инфраструктуру для
работы с JWT и JWKS:
Основной механизм проверки через удалённый JWKS:
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
)
const { payload } = await jwtVerify(token, JWKS)
Но эта модель предполагает один источник. При нескольких JWKS требуется расширение логики.
При нескольких источниках ключей появляется слой выбора JWKS перед проверкой подписи.
Ключевая идея: сначала определить, какой JWKS использовать, затем выполнить verify.
import { jwtVerify, createRemoteJWKSet } from 'jose'
const jwksMap = {
'https://issuer-a.example.com': createRemoteJWKSet(
new URL('https://issuer-a.example.com/.well-known/jwks.json')
),
'https://issuer-b.example.com': createRemoteJWKSet(
new URL('https://issuer-b.example.com/.well-known/jwks.json')
)
}
async function verify(token) {
const { payload, protectedHeader } = await jwtVerify(
token,
(header, token) => {
const issuer = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).iss
return jwksMap[issuer]
}
)
return payload
}
Однако такой подход содержит проблему: декодирование payload до проверки подписи снижает безопасность.
Безопаснее опираться на kid из заголовка JWT, не
расшифровывая payload:
import { jwtVerify, createRemoteJWKSet } from 'jose'
const jwksA = createRemoteJWKSet(new URL('https://a.example.com/.well-known/jwks.json'))
const jwksB = createRemoteJWKSet(new URL('https://b.example.com/.well-known/jwks.json'))
const sources = [jwksA, jwksB]
async function keySelector(protectedHeader) {
for (const jwks of sources) {
try {
return await jwks(protectedHeader, undefined)
} catch {
continue
}
}
throw new Error('No matching key found')
}
async function verify(token) {
return jwtVerify(token, keySelector)
}
Здесь реализована приоритизация источников по порядку: первый JWKS имеет более высокий приоритет, но при неудаче происходит fallback.
В сложных системах используется комбинированный подход:
isskidconst jwksGroups = {
'issuer-a': createRemoteJWKSet(new URL('https://a.example.com/jwks.json')),
'issuer-b': createRemoteJWKSet(new URL('https://b.example.com/jwks.json'))
}
async function verify(token) {
const { protectedHeader, payload } = await jwtVerify(token, async (header) => {
const issuer = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).iss
const jwks = jwksGroups[issuer]
if (!jwks) {
throw new Error('Unknown issuer')
}
return jwks(header, undefined)
})
return payload
}
Хотя здесь снова используется payload-декодирование, на практике это допустимо при условии предварительной валидации формата JWT и строгого контроля доверенных issuer.
createRemoteJWKSet уже содержит встроенный кеш, но при
множественных источниках важно учитывать:
Расширенный контроль:
const jwks = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
{
cooldownDuration: 5000,
cacheMaxAge: 600000
}
)
При нескольких источниках приоритет часто влияет и на стратегию обновления:
Если нет жёсткой привязки iss -> JWKS, возможна
ситуация, когда токен валидируется чужим ключом при совпадении
kid.
Решение — обязательная проверка issuer до доверия JWKS.
Если JWKS URL формируется динамически на основе токена, возникает риск SSRF-атаки.
Недопустимо:
createRemoteJWKSet(new URL(`https://${issuer}/jwks.json`))
Допустимо только через whitelist.
Разные identity provider’ы могут использовать одинаковые
kid. Без разделения по issuer это приводит к неверной
валидации.
На практике используется многоуровневая модель:
Схематично:
При множественных JWKS источниках критично:
kidПример параллельной стратегии:
async function resolveKey(header) {
const results = await Promise.any(
sources.map(jwks => jwks(header, undefined))
)
return results
}
Это сокращает latency при большом количестве провайдеров.
Приоритизация JWKS источников всегда должна учитывать уровень доверия:
Иерархия источников часто фиксируется конфигурационно:
const priority = [
'internal-auth',
'partner-auth',
'public-auth'
]
И используется для последовательного fallback.
Если два JWKS источника содержат валидный ключ для одного
kid, приоритет должен определяться не совпадением, а
источником:
Это предотвращает атаки через подмену ключей в менее доверенном источнике.
Комбинация jose и многослойной маршрутизации приводит к
следующей логике: