Вложенные JWT (Nested JWT) представляют собой композицию двух уровней защиты: сначала создаётся подписанный токен (JWS), затем он шифруется (JWE). В результате формируется структура, где подпись «внутри» шифрования и становится доступной только после расшифровки.
Такой подход применяется в системах с повышенными требованиями к безопасности: банковские API, обмен чувствительными данными между микросервисами, SSO-инфраструктуры, защищённые каналы передачи идентификационных данных.
В стандарте JOSE (JSON Object Signing and Encryption) вложенный JWT формируется по цепочке:
Итоговая структура:
JWE(
JWS(payload)
)
Это означает, что даже если злоумышленник получит токен, он увидит только зашифрованную форму. Подпись остаётся скрытой до момента расшифровки.
Отвечает за:
Отвечает за:
Вложенная модель объединяет обе гарантии:
Библиотека jose является одной из наиболее полных
реализаций стандарта JOSE в JavaScript и поддерживает как JWS, так и
JWE, включая их композицию.
Установка:
npm install jose
Первый этап — формирование подписанного JWT.
import { SignJWT, importPKCS8 } from 'jose'
const privateKey = await importPKCS8(
process.env.PRIVATE_KEY,
'RS256'
)
const payload = {
sub: 'user_123',
role: 'admin',
permissions: ['read', 'write']
}
const jws = await new SignJWT(payload)
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuedAt()
.setExpirationTime('2h')
.sign(privateKey)
На этом этапе создаётся компактный JWS:
header.payload.signature
Этот токен уже защищён от подмены, но не скрыт.
Далее полученный JWS используется как payload для шифрования.
import { EncryptJWT, importJWK } from 'jose'
const publicKey = await importJWK(
{
kty: 'RSA',
e: 'AQAB',
n: '...'
},
'RSA-OAEP-256'
)
const nestedJWT = await new EncryptJWT({ token: jws })
.setProtectedHeader({
alg: 'RSA-OAEP-256',
enc: 'A256GCM'
})
.setIssuedAt()
.setExpirationTime('2h')
.encrypt(publicKey)
Результат — JWE компактного формата:
protectedHeader.encryptedKey.iv.ciphertext.tag
Внутри ciphertext находится JWS.
import { jwtDecrypt } from 'jose'
const { plaintext } = await jwtDecrypt(
nestedJWT,
privateKey
)
const { token: extractedJWS } = JSON.parse(
new TextDecoder().decode(plaintext)
)
После расшифровки получается исходный JWS.
import { jwtVerify } from 'jose'
const { payload } = await jwtVerify(
extractedJWS,
publicKey
)
Теперь доступны проверенные данные.
Архитектура «подпись → шифрование» используется по нескольким причинам.
Без шифрования JWS содержит:
JWE скрывает это полностью.
Даже если подпись не может быть подделана, её наличие может раскрывать:
Шифрование устраняет этот риск.
Комбинация обеспечивает:
Теоретически возможна обратная схема: сначала шифрование, затем подпись. Однако она используется реже, так как:
Поэтому стандартной практикой считается именно:
JWS → JWE
После сериализации итоговый токен выглядит как строка:
eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIn0...
Внутри:
После двух этапов восстановления:
{
"sub": "user_123",
"role": "admin",
"permissions": ["read", "write"],
"iat": 1710000000,
"exp": 1710007200
}
Эти данные появляются только после:
Недопустимо:
HS256 в распределённых системах без контроля
секретовRSA1_5 вместо RSA-OAEP-256Рекомендуемые связки:
RS256, ES256RSA-OAEP-256 + A256GCMОшибка архитектуры — логировать или передавать JWS до шифрования.
Это уничтожает смысл вложенной модели.
Распространённая ошибка:
Это приводит к ситуации, где:
Правильный порядок всегда фиксирован:
Nested JWT увеличивает стоимость обработки:
В системах с высокой нагрузкой это может требовать:
Nested JWT часто используется:
Типичный поток:
Client → Gateway → Auth Service → Internal Services
На каждом этапе токен может оставаться зашифрованным, но проверяемым после расшифровки.
Nested JWT создаёт разделение доверия:
Это делает модель устойчивой к:
Payload
↓
JWS (подпись)
↓
JWE (шифрование)
↓
Transport
↓
Decrypt JWE
↓
Verify JWS
↓
Trusted payload
Библиотека jose предоставляет:
Ключевая особенность — отсутствие «магии»: каждый слой реализуется явно, что важно для построения предсказуемой криптографической цепочки.