Практика: верификация JWT-токенов вручную

JWT (JSON Web Token) представляет собой компактный токен, состоящий из трёх частей, разделённых точками:

  • Header (заголовок) — содержит метаданные, включая алгоритм подписи
  • Payload (полезная нагрузка) — содержит утверждения (claims)
  • Signature (подпись) — криптографическая гарантия целостности

Формат:

header.payload.signature

Каждая часть закодирована в Base64URL, что требует отдельной обработки при декодировании и проверке.


Декодирование Base64URL

Web Crypto API не предоставляет встроенного декодера Base64URL, поэтому преобразование выполняется вручную:

function base64UrlDecode(str) {
  const base64 = str
    .replace(/-/g, '+')
    .replace(/_/g, '/')
    .padEnd(str.length + (4 - (str.length % 4)) % 4, '=');

  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);

  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }

  return bytes;
}

Для JSON-частей:

function decodeJwtPart(part) {
  const bytes = base64UrlDecode(part);
  return JSON.parse(new TextDecoder().decode(bytes));
}

Разбор JWT на части

function parseJwt(token) {
  const [header, payload, signature] = token.split('.');

  return {
    header: decodeJwtPart(header),
    payload: decodeJwtPart(payload),
    signature: base64UrlDecode(signature)
  };
}

Проверка алгоритма подписи

Перед криптографической проверкой важно убедиться, что алгоритм соответствует ожидаемому:

function validateAlg(header, expectedAlg) {
  if (header.alg !== expectedAlg) {
    throw new Error('Unsupported algorithm');
  }
}

На практике часто используются:

  • HS256 — HMAC + SHA-256
  • RS256 — RSA + SHA-256

Проверка JWT с HS256 (HMAC)

HS256 использует симметричный ключ. Web Crypto API позволяет проверить подпись через HMAC.

Импорт ключа

async function importHmacKey(secret) {
  const keyData = new TextEncoder().encode(secret);

  return crypto.subtle.importKey(
    'raw',
    keyData,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify']
  );
}

Формирование данных для проверки

Подпись считается по строке:

header.payload
function getSignedData(token) {
  const [header, payload] = token.split('.');
  return new TextEncoder().encode(`${header}.${payload}`);
}

Верификация подписи

async function verifyHs256(token, secret) {
  const { signature } = parseJwt(token);
  const key = await importHmacKey(secret);

  const data = getSignedData(token);

  return crypto.subtle.verify(
    'HMAC',
    key,
    signature,
    data
  );
}

Проверка JWT с RS256 (RSA)

RS256 использует асимметричную криптографию: публичный ключ применяется для проверки подписи.


Импорт публичного ключа

Публичный ключ обычно предоставляется в формате SPKI (SubjectPublicKeyInfo).

async function importRsaPublicKey(pem) {
  const binary = pem
    .replace(/-----BEGIN PUBLIC KEY-----/, '')
    .replace(/-----END PUBLIC KEY-----/, '')
    .replace(/\s/g, '');

  const der = base64UrlDecode(binary);

  return crypto.subtle.importKey(
    'spki',
    der,
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['verify']
  );
}

Проверка RS256 подписи

async function verifyRs256(token, publicKey) {
  const { signature } = parseJwt(token);

  const key = await importRsaPublicKey(publicKey);
  const data = getSignedData(token);

  return crypto.subtle.verify(
    'RSASSA-PKCS1-v1_5',
    key,
    signature,
    data
  );
}

Полный процесс верификации JWT

Общий алгоритм включает несколько этапов:

  1. Разбор токена
  2. Проверка алгоритма
  3. Проверка подписи
  4. Дополнительная проверка payload (exp, iss, aud)

Унифицированная функция

async function verifyJwt(token, options) {
  const { header, payload } = parseJwt(token);

  validateAlg(header, options.alg);

  let isValid = false;

  if (header.alg === 'HS256') {
    isValid = await verifyHs256(token, options.secret);
  }

  if (header.alg === 'RS256') {
    isValid = await verifyRs256(token, options.publicKey);
  }

  if (!isValid) {
    return false;
  }

  return validateClaims(payload, options);
}

Проверка стандартных claims

JWT payload часто содержит служебные поля:

  • exp — время истечения
  • nbf — начало действия
  • iat — время выпуска
  • iss — издатель
  • aud — аудитория

Проверка времени

function validateTimeClaims(payload) {
  const now = Math.floor(Date.now() / 1000);

  if (payload.exp && now >= payload.exp) {
    return false;
  }

  if (payload.nbf && now < payload.nbf) {
    return false;
  }

  return true;
}

Проверка издателя и аудитории

function validateClaims(payload, options) {
  if (!validateTimeClaims(payload)) {
    return false;
  }

  if (options.issuer && payload.iss !== options.issuer) {
    return false;
  }

  if (options.audience && payload.aud !== options.audience) {
    return false;
  }

  return true;
}

Особенности работы Web Crypto API

Web Crypto API работает асинхронно и строго изолированно:

  • операции выполняются через crypto.subtle
  • ключи не могут быть извлечены после импорта (non-extractable)
  • поддерживаются только безопасные контексты (HTTPS)

Обработка ошибок криптографической проверки

Типовые причины отказа в проверке:

  • несоответствие алгоритма
  • повреждённая подпись
  • неверный ключ
  • некорректная кодировка Base64URL
  • несоответствие header.payload при подписании
try {
  const result = await verifyJwt(token, config);
} catch (e) {
  // криптографическая ошибка или некорректный формат
}

Безопасные практики обработки JWT

При работе с Web Crypto API критично учитывать:

  • запрет на использование none алгоритма
  • явная фиксация алгоритма при проверке
  • отказ от доверия данным header без проверки подписи
  • исключение ручной модификации payload до верификации

Разделение ответственности в проверке

Корректная архитектура проверки JWT обычно разделяется на уровни:

  • криптографическая проверка подписи (Web Crypto API)
  • семантическая проверка claims
  • бизнес-валидация (роли, права доступа)

Такое разделение снижает риск логических ошибок и упрощает аудит безопасности.