Проверка всех полей перед доверием данным в JWT/JWS критична, поскольку криптографическая подпись гарантирует лишь неизменность токена, но не его семантическую корректность в рамках конкретного приложения. Библиотека Jsrsasign предоставляет низкоуровневые и среднеуровневые средства для работы с JWS, однако ответственность за строгую валидацию структуры и содержимого полностью ложится на разработчика.
Любая работа с JWT должна быть разложена на два независимых этапа:
Важно исключить использование данных до завершения первого этапа.
import { KJUR } from 'jsrsasign';
const jwt = "header.payload.signature";
const publicKey = `-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----`;
// Проверка подписи
const isValid = KJUR.jws.JWS.verify(jwt, publicKey, ["RS256"]);
if (!isValid) {
throw new Error("Invalid signature");
}
// Только после этого можно извлекать данные
const parsed = KJUR.jws.JWS.parse(jwt);
const header = parsed.headerObj;
const payload = parsed.payloadObj;
Одной из наиболее частых уязвимостей является подмена алгоритма
(alg). Даже при проверке подписи необходимо явно
ограничивать допустимые алгоритмы.
const allowedAlgs = ["RS256"];
if (!allowedAlgs.includes(header.alg)) {
throw new Error("Disallowed signing algorithm");
}
Игнорирование этого шага может привести к атакам вида “algorithm confusion”, когда токен, подписанный симметричным алгоритмом, принимается как асимметричный.
После успешной проверки подписи необходимо убедиться, что все критические поля присутствуют и имеют корректный формат.
Наиболее часто проверяемые claims:
iss (issuer)sub (subject)aud (audience)exp (expiration time)nbf (not before)iat (issued at)jti (JWT ID)Пример строгой проверки:
function validatePayload(payload) {
if (!payload.iss || typeof payload.iss !== "string") {
throw new Error("Invalid iss");
}
if (!payload.sub || typeof payload.sub !== "string") {
throw new Error("Invalid sub");
}
if (!payload.aud) {
throw new Error("Missing aud");
}
if (typeof payload.exp !== "number") {
throw new Error("Invalid exp");
}
}
Jsrsasign не навязывает автоматическую проверку времени, поэтому её необходимо реализовать явно.
const now = Math.floor(Date.now() / 1000);
if (payload.nbf && now < payload.nbf) {
throw new Error("Token not active yet");
}
if (payload.exp && now >= payload.exp) {
throw new Error("Token expired");
}
if (payload.iat && payload.iat > now + 60) {
throw new Error("Invalid issued-at time");
}
Допускается небольшая временная дельта (clock skew), но она должна быть ограниченной и явно заданной.
Поле aud может быть строкой или массивом. Ошибка
обработки этого поля часто приводит к приёму токенов, предназначенных
для другого сервиса.
function validateAudience(aud, expectedAud) {
if (Array.isArray(aud)) {
if (!aud.includes(expectedAud)) {
throw new Error("Audience mismatch");
}
} else {
if (aud !== expectedAud) {
throw new Error("Audience mismatch");
}
}
}
Аналогично проверяется iss:
if (payload.iss !== "https://auth.example.com") {
throw new Error("Invalid issuer");
}
Header JWS также требует обязательной проверки, поскольку он может содержать критически важные указания:
alg — алгоритм подписиkid — идентификатор ключаtyp — тип токенаcrit — критические параметрыkidНельзя напрямую доверять ключу, выбранному по kid, без
валидации:
const keyMap = {
"key1": publicKey1,
"key2": publicKey2
};
if (!header.kid || !keyMap[header.kid]) {
throw new Error("Unknown key id");
}
const keyToUse = keyMap[header.kid];
typif (header.typ && header.typ !== "JWT") {
throw new Error("Unexpected token type");
}
critПараметр crit обозначает обязательные расширения. Если
приложение не поддерживает указанные параметры — токен должен быть
отклонён.
if (header.crit && header.crit.length > 0) {
throw new Error("Unsupported critical headers");
}
Jsrsasign предоставляет parse и verify, но
не выполняет бизнес-валидацию:
function verifyToken(jwt, key) {
const allowedAlgs = ["RS256"];
const isValid = KJUR.jws.JWS.verify(jwt, key, allowedAlgs);
if (!isValid) {
throw new Error("Signature invalid");
}
const { headerObj, payloadObj } = KJUR.jws.JWS.parse(jwt);
if (!allowedAlgs.includes(headerObj.alg)) {
throw new Error("Algorithm not allowed");
}
if (headerObj.kid && typeof headerObj.kid !== "string") {
throw new Error("Invalid kid");
}
validatePayload(payloadObj);
const now = Math.floor(Date.now() / 1000);
if (payloadObj.exp && now >= payloadObj.exp) {
throw new Error("Expired token");
}
if (payloadObj.nbf && now < payloadObj.nbf) {
throw new Error("Token not active");
}
validateAudience(payloadObj.aud, "my-service");
if (payloadObj.iss !== "https://auth.example.com") {
throw new Error("Invalid issuer");
}
return payloadObj;
}
Даже при наличии подписи данные могут быть некорректными по типу:
exp, iat, nbf должны быть
числамиaud — строка или массив строкif (payload.role && typeof payload.role !== "string") {
throw new Error("Invalid role type");
}
if (payload.permissions && !Array.isArray(payload.permissions)) {
throw new Error("Invalid permissions format");
}
Подписанный токен может оставаться валидным, но быть логически неприемлемым:
adminПоэтому любая бизнес-логика должна строиться на строгой проверке всех значимых полей:
if (payload.role === "admin" && !isAdminContextAllowed()) {
throw new Error("Privilege escalation blocked");
}
Jsrsasign возвращает payload как объект, но он требует нормализации:
function normalizePayload(payload) {
return {
sub: String(payload.sub),
iss: String(payload.iss),
aud: payload.aud,
exp: Number(payload.exp),
iat: Number(payload.iat),
roles: Array.isArray(payload.roles) ? payload.roles : []
};
}
Даже при успешной верификации подписи:
kid может указывать на неожиданный ключ при
неправильной конфигурацииЛюбая точка доступа к данным токена должна быть защищена многоуровневой проверкой, где Jsrsasign используется только как криптографический слой, а вся логика доверия реализуется поверх него.