JWT Decoder JavaScript — atob(), TextDecoder и jose

·JavaScript Performance Engineer·ПровереноSophie Laurent·Опубликовано

Используйте бесплатный JWT Decoder прямо в браузере — установка не требуется.

Попробовать JWT Decoder онлайн →

Каждый процесс аутентификации, который я создавал, рано или поздно приходит к одной и той же точке: у вас есть JWT в куки, заголовке или URL OAuth-коллбэка, и нужно прочитать, что внутри. JWT-декодер на JavaScript не требует ни одного npm-пакета. Заголовок и полезная нагрузка токена — это просто JSON в кодировке Base64url, и браузер, и Node.js содержат всё необходимое для их декодирования. Это руководство охватывает полный конвейер декодирования текста в JavaScript для JWT: разбиение токена, нормализацию base64url до стандартного Base64, atob() и TextDecoder для правильной обработки UTF-8, Node.js Buffer.from(), верификацию подписи с помощью jose, а также типичные ошибки, на которых регулярно спотыкаются разработчики. Для быстрой одиночной проверки воспользуйтесь онлайн-декодером JWT вместо этого. Все примеры рассчитаны на ES2020+ и Node.js 18+.

  • Разбейте JWT по символу "." — индекс 0 это заголовок, индекс 1 это полезная нагрузка, индекс 2 это подпись.
  • atob() декодирует Base64, но возвращает Latin-1, а не UTF-8. Используйте TextDecoder или Buffer.from() для не-ASCII утверждений.
  • Buffer.from(segment, "base64url") нативно обрабатывает base64url в Node.js — ручная замена символов не нужна.
  • Декодирование — это НЕ верификация. Никогда не доверяйте утверждениям из декодированного JWT без проверки подписи на сервере.
  • Библиотека jose делает оба: проверяет подписи HS256/RS256/ES256 и возвращает декодированную полезную нагрузку за один вызов.

Что такое декодирование JWT?

JSON Web Token состоит из трёх сегментов в кодировке Base64url, разделённых точками. Первый сегмент — заголовок, второй — полезная нагрузка (утверждения, которые вас интересуют), третий — криптографическая подпись. Заголовок — это небольшой JSON-объект, описывающий сам токен. Его наиболее важное поле — alg — алгоритм подписи (например, HS256, RS256, ES256). Поле typ почти всегда равно "JWT", а необязательное поле kid идентифицирует ключ, которым был подписан токен — это критично, когда провайдер идентификации ротирует ключи и публикует JWKS-эндпоинт с несколькими открытыми ключами.

Полезная нагрузка содержит утверждения. RFC 7519 определяет семь зарегистрированных имён утверждений: sub (субъект — обычно идентификатор пользователя), iss (издатель — URL сервера аутентификации), aud (аудитория — API, для которого предназначен токен), iat (временная метка выпуска), exp (временная метка истечения срока), nbf (временная метка «не раньше чем») и jti (идентификатор JWT — используется для предотвращения атак воспроизведения). Все временные метки — это секунды Unix-эпохи, а не миллисекунды. Сегмент подписи — это сырые бинарные данные: хэш HMAC с ключом или асимметричная цифровая подпись. Он закодирован в Base64url, как и другие сегменты, но его байты не являются JSON и не имеют читаемой человеком структуры.

На практике JWT в JavaScript декодируют по трём распространённым причинам. Первая — отладка: у вас есть токен из OAuth-потока или тестовой среды, и вы хотите убедиться, что утверждения соответствуют тому, что должен был выдать сервер аутентификации. Вторая — чтение пользовательских утверждений для отображения на стороне клиента: показать имя вошедшего пользователя, URL аватара или метку роли из полезной нагрузки токена без лишнего запроса к API. Третья — проверка срока действия перед обновлением: если exp истекает в течение следующих 60 секунд, выполните тихое обновление перед следующим вызовом API, не дожидаясь ответа 401.

Декодирование не проверяет, действителен ли токен и не был ли он подделан. Это отдельная операция — верификация, которая требует секрета HMAC или открытого ключа RSA/ECDSA. Любой может декодировать JWT. Только владелец правильного ключа может его проверить. Это различие сбивает с толку многих разработчиков, особенно при создании клиентских процессов аутентификации, где декодированные утверждения отображаются, но никогда не должны использоваться для принятия решений об авторизации без верифицированной проверки на бэкенде.

Before · json
After · json
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
// Header
{ "alg": "HS256" }

// Payload
{
  "sub": "usr_921f",
  "role": "admin",
  "iat": 1711610000
}

atob() + TextDecoder — Нативное декодирование JWT в браузере

Нативный браузерный конвейер для декодирования JWT состоит из четырёх шагов. Первый — разбить строку токена по символу "." на три сегмента. Второй — нормализовать сегмент base64url, заменив - на + и _ на /, а затем дополнив символами = до длины, кратной 4. Третий — вызвать atob() для декодирования Base64 в бинарную строку. Четвёртый — преобразовать бинарную строку в корректный UTF-8 с помощью TextDecoder. Последний шаг важен, потому что atob() возвращает Latin-1. Многобайтовые символы — эмодзи, текст CJK, символы с диакритикой за пределами диапазона Latin-1 — будут искажены без шага декодирования текста JavaScript.

JavaScript — минимальное декодирование JWT
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";

function decodeJwtPayload(jwt) {
  const base64Url = jwt.split(".")[1];
  const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
  const binary = atob(padded);
  const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
  const json = new TextDecoder("utf-8").decode(bytes);
  return JSON.parse(json);
}

console.log(decodeJwtPayload(token));
// { sub: "usr_921f", role: "admin", iat: 1711610000 }

Шаг дополнения легко упустить. JWT отбрасывает завершающие символы = из своих сегментов Base64url, поскольку спецификация JWT (RFC 7515) определяет base64url без дополнения. Но atob() в некоторых браузерных движках выбрасывает InvalidCharacterError, если длина входных данных не кратна 4. Защитное дополнение с помощью padEnd() позволяет избежать этого крайнего случая во всех средах. Вот переиспользуемая версия, которая декодирует и заголовок, и полезную нагрузку в отдельные объекты:

JavaScript — декодирование заголовка и полезной нагрузки
function decodeBase64Url(segment) {
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
  const binary = atob(padded);
  const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
  return new TextDecoder("utf-8").decode(bytes);
}

function decodeJwt(token) {
  const [headerB64, payloadB64] = token.split(".");
  return {
    header: JSON.parse(decodeBase64Url(headerB64)),
    payload: JSON.parse(decodeBase64Url(payloadB64)),
  };
}

const { header, payload } = decodeJwt(token);
console.log("Algorithm:", header.alg);   // "HS256"
console.log("Subject:", payload.sub);     // "usr_921f"
console.log("Role:", payload.role);       // "admin"

Получив эти две функции, стоит поместить их в общий утилитарный модуль, а не копировать логику по всем файлам. Файл src/lib/jwt.ts или utils/jwt-decode.ts с типизированной возвращаемой формой явно указывает на назначение по всей кодовой базе. В TypeScript можно типизировать возвращаемое значение как { header: JwtHeader; payload: JwtPayload }, где JwtHeader включает alg, typ и необязательный kid, а JwtPayload расширяет зарегистрированные утверждения RFC 7519 индексной сигнатурой для пользовательских утверждений. Централизация логики декодирования означает, что когда вы захотите добавить обработку ошибок (перехват некорректных сегментов) или телеметрию (логирование сбоев декодирования), нужно будет обновить только одно место.

Примечание:Шаг TextDecoder делает этот конвейер безопасным для не-ASCII утверждений. Без него atob() возвращает строку Latin-1, в которой многобайтовые UTF-8 последовательности разбиваются на отдельные символы. Вместо эмодзи или текста CJK вы получите мусор. Всегда пропускайте результат через new TextDecoder("utf-8") после atob().

Декодирование UTF-8 утверждений JWT с многобайтовыми символами

Полезные нагрузки JWT — это JSON в кодировке UTF-8, закодированный в base64url. Большинство полезных нагрузок содержат только ASCII-поля, например идентификаторы пользователей и временные метки, поэтому разработчики не замечают, что atob() возвращает Latin-1 вместо UTF-8. Проблема проявляется в тот момент, когда утверждение содержит эмодзи, кириллицу или любую кодовую точку выше U+00FF. Шаблон декодирования UTF-8 в JavaScript требует сначала преобразовать бинарную строку в байтовый массив, а затем передать его через TextDecoder.

JavaScript — полный цикл UTF-8 с кириллицей в полезной нагрузке JWT
// Имитация полезной нагрузки JWT с кириллицей и эмодзи
const payloadObj = {
  sub: "usr_e821",
  display_name: "Алексей Иванов",
  team: "Platform 🚀",
  region: "ru-central-1"
};

// Кодирование: объект → JSON → байты UTF-8 → base64url
const jsonStr = JSON.stringify(payloadObj);
const utf8Bytes = new TextEncoder().encode(jsonStr);
const base64 = btoa(String.fromCharCode(...utf8Bytes))
  .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

// Декодирование: base64url → base64 → бинарная строка → байты → строка UTF-8
const base64Std = base64.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64Std);
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const decoded = new TextDecoder("utf-8").decode(bytes);
const result = JSON.parse(decoded);

console.log(result.display_name); // "Алексей Иванов" — корректно
console.log(result.team);         // "Platform 🚀" — корректно

В старых кодовых базах вы встретите устаревший запасной вариант, использующий decodeURIComponent в сочетании с приёмом процентного кодирования. Этот подход decodeURIComponent в JavaScript работает, потому что перекодирует каждый байт как пару шестнадцатеричных символов с процентом, а затем decodeURIComponent собирает многобайтовые UTF-8 последовательности обратно:

JavaScript — устаревший запасной вариант decodeURIComponent для UTF-8
function decodeBase64UrlLegacy(segment) {
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const binary = atob(base64);
  // Преобразование каждого символа в %XX hex, затем decodeURIComponent собирает UTF-8
  const utf8 = decodeURIComponent(
    binary.split("").map(c =>
      "%" + c.charCodeAt(0).toString(16).padStart(2, "0")
    ).join("")
  );
  return utf8;
}

// Работает для не-ASCII утверждений без TextDecoder
const payload = decodeBase64UrlLegacy(token.split(".")[1]);
console.log(JSON.parse(payload));
Предупреждение:В старых утилитарных фрагментах JWT вы можете встретить паттерн decodeURIComponent(escape(atob(segment))). Функция escape() устарела и нестандартна. Замените её на показанный выше подход с TextDecoder. Паттерн JavaScript unescape декодирования имеет ту же проблему: unescape() устарела. Обе функции могут быть удалены из будущих движков JavaScript.

Конвейер декодирования JWT — справочник по шагам

Каждый шаг нативного браузерного конвейера декодирования JWT с используемым API JavaScript и тем, что он производит:

Параметр / Шаг
Тип
Описание
token.split(".")
string[]
Разбивает JWT на сегменты [заголовок, полезная нагрузка, подпись]
base64url → base64
string replace
Заменяет - на +, _ на /, дополняет = до кратности 4
atob(base64)
string
Декодирует стандартную строку Base64 в бинарную строку (Latin-1)
TextDecoder("utf-8")
TextDecoder
Преобразует Uint8Array из сырых байт в корректную строку UTF-8
JSON.parse()
object
Разбирает результирующую JSON-строку в объект JavaScript

Аналог в Node.js сворачивает шаги 2–4 в один вызов: Buffer.from(segment, "base64url").toString("utf-8"). Опция кодировки "base64url" обрабатывает преобразование алфавита и дополнение внутренне.

Buffer.from() — Строковый декодер Node.js для JWT

В Node.js есть гораздо более простой путь. Класс Buffer принимает кодировку "base64url" напрямую, поэтому вы пропускаете ручную замену символов и дополнение. Это путь строкового декодера JavaScript для серверного кода. Одна строка превращает сегмент JWT в строку UTF-8 и корректно обрабатывает многобайтовые символы без дополнительных шагов.

Node.js 18+ — декодирование JWT с Buffer
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsIm9yZyI6ImFjbWUtY29ycCIsInJvbGUiOiJiaWxsaW5nIiwiaWF0IjoxNzExNjEwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";

function decodeJwt(jwt) {
  const segments = jwt.split(".");
  return {
    header: JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8")),
    payload: JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8")),
  };
}

const { header, payload } = decodeJwt(token);
console.log(header);
// { alg: "HS256" }
console.log(payload);
// { sub: "usr_921f", org: "acme-corp", role: "billing", iat: 1711610000 }

Именно к этому подходу я обращаюсь в каждом Node.js-проекте. Он короче, быстрее и уже правильно обрабатывает UTF-8. Никакого TextDecoder, никакой замены символов, никаких вычислений дополнения. Класс Buffer — это строковый декодер JavaScript, который нативно обрабатывает алфавит base64url, устраняя целый класс ошибок, связанных с заменой символов. Если ваш код должен работать и в браузере, и в Node.js, см. раздел FAQ ниже с изоморфной функцией-обёрткой, определяющей окружение во время выполнения.

Вот более полный пример, показывающий извлечение типичных утверждений JWT и преобразование временных меток в читаемые даты — это паттерн, который вы будете использовать чаще всего в middleware и обработчиках API-маршрутов:

Node.js — практическое извлечение утверждений JWT
function inspectToken(token) {
  const segments = token.split(".");
  if (segments.length !== 3) {
    throw new Error("Not a valid JWT — expected 3 dot-separated segments");
  }

  const header = JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8"));
  const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8"));

  const inspection = {
    algorithm: header.alg,
    tokenType: header.typ || "JWT",
    subject: payload.sub,
    issuer: payload.iss || "(not set)",
    audience: payload.aud || "(not set)",
    issuedAt: payload.iat ? new Date(payload.iat * 1000).toISOString() : "(not set)",
    expiresAt: payload.exp ? new Date(payload.exp * 1000).toISOString() : "(never)",
    isExpired: payload.exp ? payload.exp < Math.floor(Date.now() / 1000) : false,
    customClaims: Object.keys(payload).filter(
      k => !["sub", "iss", "aud", "iat", "exp", "nbf", "jti"].includes(k)
    ),
  };

  return inspection;
}

console.log(inspectToken(process.env.ACCESS_TOKEN));
// {
//   algorithm: "RS256",
//   tokenType: "JWT",
//   subject: "usr_921f",
//   issuer: "https://auth.internal",
//   audience: "billing-api",
//   issuedAt: "2026-03-10T14:00:00.000Z",
//   expiresAt: "2026-03-10T15:00:00.000Z",
//   isExpired: true,
//   customClaims: ["role", "scope", "org"]
// }

В production-сервисах Node.js паттерн декодирования Buffer.from() встречается в трёх повторяющихся местах. Первое — middleware логирования запросов: вы декодируете входящий заголовок Authorization, чтобы добавить userId и org в каждую структурированную запись лога без дополнительного сетевого обращения к серверу аутентификации. Второе — отладка: вы выводите декодированные утверждения токена в консоль во время разработки, чтобы убедиться, что выданы правильные области доступа, прежде чем писать тестовые утверждения. Третье — проактивное обновление токенов в API-шлюзах. Вместо того чтобы передавать токен дальше и позволить нижестоящему сервису вернуть 401 при истечении срока действия токена в середине запроса, шлюз декодирует токен на краю, читает утверждение exp и инициирует обновление, если срок истекает в течение следующих 30 секунд. Это устраняет класс временных сбоев аутентификации, которые сложно воспроизвести и неприятно отлаживать.

Примечание:Кодировка "base64url" была добавлена в Node.js 15.7.0. Если вы застряли на Node.js 14 или более ранней версии, используйте Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64"), что работает так же, но требует ручной замены символов.

Декодирование JWT из файла и ответа API

Два сценария возникают постоянно. Первый — чтение JWT из локального файла: сохранённый токен во время разработки, тестовая фикстура или файл, сброшенный в ходе инцидента для постмортем-анализа. Второй — извлечение JWT из HTTP-ответа, обычно из поля access_token в теле ответа OAuth или из заголовка Authorization. Оба требуют обработки ошибок, поскольку некорректные токены, усечённые файлы и сетевые ошибки — это повседневная реальность. Токен, который был действителен на прошлой неделе, может содержать завершающие пробелы или переводы строк после копирования. Тело ответа может оказаться HTML вместо JSON, если сервер аутентификации вернул страницу ошибки.

Чтение JWT из файла (Node.js)

Node.js — декодирование JWT из файла с обработкой ошибок
import { readFileSync } from "node:fs";

function decodeJwtFromFile(filePath) {
  const raw = readFileSync(filePath, "utf-8").trim();
  const segments = raw.split(".");

  if (segments.length !== 3) {
    throw new Error(`Invalid JWT: expected 3 segments, got ${segments.length}`);
  }

  try {
    return {
      header: JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8")),
      payload: JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8")),
    };
  } catch (err) {
    throw new Error(`Failed to decode JWT from ${filePath}: ${err.message}`);
  }
}

try {
  const { header, payload } = decodeJwtFromFile("./test-fixtures/access-token.txt");
  console.log("Algorithm:", header.alg);
  console.log("Expires:", new Date(payload.exp * 1000).toISOString());
} catch (err) {
  console.error(err.message);
}

Извлечение JWT из ответа API (fetch)

JavaScript — декодирование JWT из ответа API
async function fetchAndDecodeToken(loginUrl, credentials) {
  const response = await fetch(loginUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(credentials),
  });

  if (!response.ok) {
    throw new Error(`Login failed: ${response.status} ${response.statusText}`);
  }

  const { access_token } = await response.json();
  if (!access_token || access_token.split(".").length !== 3) {
    throw new Error("Response does not contain a valid JWT");
  }

  const payload = access_token.split(".")[1];
  const json = Buffer.from(payload, "base64url").toString("utf-8");
  return JSON.parse(json);
}

// Использование
try {
  const claims = await fetchAndDecodeToken(
    "https://auth.internal/oauth/token",
    { username: "deploy-bot", password: process.env.DEPLOY_TOKEN }
  );
  console.log("Token subject:", claims.sub);
  console.log("Token scopes:", claims.scope);
  console.log("Expires at:", new Date(claims.exp * 1000).toISOString());
} catch (err) {
  console.error("Token decode error:", err.message);
}

Декодирование JWT из командной строки

Иногда нужно просто взглянуть на токен из терминала, не создавая скрипт. Node.js доступен на большинстве машин разработчиков, поэтому однострочник отлично подойдёт. jq берёт на себя форматирование вывода.

bash — декодирование полезной нагрузки JWT из терминала
# Декодирование полезной нагрузки JWT с помощью однострочника Node.js
echo "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiJ9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" \
  | cut -d. -f2 \
  | node -e "process.stdin.on('data', d => console.log(JSON.parse(Buffer.from(d.toString().trim(), 'base64url').toString('utf-8'))))"

# Передача в jq для форматированного вывода
echo "$JWT_TOKEN" | cut -d. -f2 \
  | node -e "process.stdin.on('data', d => process.stdout.write(Buffer.from(d.toString().trim(), 'base64url').toString('utf-8')))" \
  | jq .

# Декодирование заголовка и полезной нагрузки
echo "$JWT_TOKEN" | node -e "
  process.stdin.on('data', d => {
    const parts = d.toString().trim().split('.');
    console.log('Header:', JSON.parse(Buffer.from(parts[0], 'base64url').toString()));
    console.log('Payload:', JSON.parse(Buffer.from(parts[1], 'base64url').toString()));
  });
"

Если вы предпочитаете чистый bash без Node.js, передайте сегмент через base64 -d после исправления символов base64url с помощью tr:

bash — чистый bash декодирование JWT без Node.js
# Чистый bash: декодирование полезной нагрузки JWT без Node.js
echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

# Вариант macOS (base64 -D вместо -d)
echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -D 2>/dev/null | jq .

Для быстрого визуального просмотра без терминала вставьте ваш токен в ToolDeck JWT Decoder для разбора всех трёх сегментов рядом с цветными метками утверждений и статусом срока действия.

jose — Верификация и декодирование в одной библиотеке

Для production-middleware аутентификации вам нужна верификация подписи, а не просто декодирование. Библиотека jose — лучший вариант. Она работает как в Node.js, так и в браузерах (через Web Crypto API), поддерживает HS256, RS256, ES256, EdDSA и JWE (зашифрованные токены) и не имеет нативных зависимостей. Установка: npm install jose.

JavaScript — jose: верификация токена HS256
import * as jose from "jose";

const secret = new TextEncoder().encode("k8s-webhook-signing-secret-2026");
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInNjb3BlIjoiYmlsbGluZzpyZWFkIiwiaWF0IjoxNzExNjEwMDAwLCJleHAiOjE3MTE2MTM2MDB9.abc123";

try {
  const { payload, protectedHeader } = await jose.jwtVerify(token, secret);
  console.log("Algorithm:", protectedHeader.alg); // "HS256"
  console.log("Subject:", payload.sub);            // "usr_921f"
  console.log("Scope:", payload.scope);            // "billing:read"
} catch (err) {
  if (err.code === "ERR_JWT_EXPIRED") {
    console.error("Token expired at:", err.payload.exp);
  } else {
    console.error("Verification failed:", err.message);
  }
}
JavaScript — jose: верификация RS256 через JWKS-эндпоинт
import * as jose from "jose";

// Получение набора открытых ключей от провайдера идентификации
const jwks = jose.createRemoteJWKSet(
  new URL("https://auth.internal/.well-known/jwks.json")
);

const token = req.headers.authorization?.split(" ")[1];
if (!token) {
  return res.status(401).json({ error: "Missing token" });
}

try {
  const { payload } = await jose.jwtVerify(token, jwks, {
    issuer: "https://auth.internal",
    audience: "billing-api",
  });
  // payload.sub, payload.scope и т.д. теперь проверены
  req.userId = payload.sub;
} catch (err) {
  return res.status(401).json({ error: "Invalid token" });
}

При выборе между jose и более старым пакетом jsonwebtoken ключевое различие — область применения в зависимости от среды выполнения. jsonwebtoken работает только в Node.js — он полагается на встроенный модуль crypto и не будет собираться для браузера. jose полностью изоморфен: он использует Web Crypto API, который доступен во всех современных браузерах, Node.js 16+, Deno, Bun и Cloudflare Workers. Если ваша логика аутентификации находится в файле Next.js middleware (работающем в Edge Runtime), в Cloudflare Worker или в общей утилите, импортируемой как серверным, так и клиентским кодом, jose — правильный выбор, поскольку у него нет нативных зависимостей и он устанавливается без шага сборки. jsonwebtoken остаётся разумным вариантом для чистых Node.js-серверных приложений, где вам нужна его более широкая экосистема вспомогательных средств подписи и вы не планируете запускать код в edge-среде. В новом проекте в 2026 году выбирайте jose по умолчанию, если нет особых причин предпочесть старый API.

Если вам нужно только декодирование без верификации, jose предоставляет jose.decodeJwt(token), которая возвращает полезную нагрузку, и jose.decodeProtectedHeader(token) для заголовка. Это удобные функции, которые выполняют декодирование Base64url внутри. Но главная причина обращаться к jose в том, что редко следует декодировать без одновременной верификации. На стороне клиента, если нужно только показать отображаемое имя пользователя или URL аватара из утверждений токена, декодирование без верификации приемлемо. На стороне сервера — всегда верифицируйте. Я видел production-системы, которые декодировали утверждения JWT для принятия решений об управлении доступом без проверки подписи, — это открытая дверь для любого злоумышленника, понимающего формат JWT.

JavaScript — jose.decodeJwt для сценариев только с декодированием
import * as jose from "jose";

// Только декодирование: секрет не нужен, верификация не выполняется
const payload = jose.decodeJwt(token);
console.log(payload.sub);   // "usr_921f"
console.log(payload.scope); // "billing:read"

const header = jose.decodeProtectedHeader(token);
console.log(header.alg);    // "HS256"
console.log(header.typ);    // "JWT"

// Проверка срока действия без верификации (отображение на стороне клиента)
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
  console.log("Token has expired — redirect to login");
}

Вывод в терминале с подсветкой синтаксиса

При отладке JWT-токенов в CLI-инструменте Node.js или во время инцидента цветной вывод реально помогает. Библиотека chalk в сочетании с JSON.stringify справляется с задачей. Установка: npm install chalk.

Node.js — цветной вывод декодированного JWT
import chalk from "chalk";

function printJwt(token) {
  const segments = token.split(".");
  if (segments.length !== 3) {
    console.error(chalk.red("Invalid JWT: expected 3 segments"));
    return;
  }

  const header = JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8"));
  const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8"));

  console.log(chalk.bold.cyan("\n=== JWT Header ==="));
  console.log(chalk.gray(JSON.stringify(header, null, 2)));

  console.log(chalk.bold.green("\n=== JWT Payload ==="));
  console.log(chalk.gray(JSON.stringify(payload, null, 2)));

  // Подсветка статуса истечения срока действия
  if (payload.exp) {
    const expiresAt = new Date(payload.exp * 1000);
    const isExpired = expiresAt < new Date();
    console.log(
      chalk.bold("\nExpires:"),
      isExpired
        ? chalk.red(`EXPIRED at ${expiresAt.toISOString()}`)
        : chalk.green(`Valid until ${expiresAt.toISOString()}`)
    );
  }

  console.log(chalk.dim("\nSignature: " + segments[2].substring(0, 20) + "..."));
}

printJwt(process.argv[2]);
// Run: node jwt-debug.mjs "eyJhbGci..."
Примечание:Цветной вывод предназначен только для терминала. Не используйте chalk при записи утверждений JWT в лог-файлы, API-ответы или поля базы данных. Управляющие последовательности ANSI будут отображаться как мусор в не-терминальных контекстах.

Обработка JWT из больших лог-файлов

Современная API-инфраструктура генерирует структурированные логи доступа в формате NDJSON — один JSON-объект на строку, каждая строка содержит путь запроса, статус ответа, задержку и декодированный или сырой заголовок Authorization. На нагруженном сервисе эти файлы быстро растут: шлюз, обрабатывающий 10 000 запросов в минуту, генерирует более 14 миллионов записей в день. Задачи безопасности и соответствия требованиям регулярно требуют последующего сканирования этих файлов — выявления каждого запроса, сделанного скомпрометированной учётной записью (постмортем-анализ), подтверждения того, что токены конкретного пользователя истекли до окна доступа к данным (аудит соответствия), или извлечения полного набора субъектов, обращавшихся к чувствительному эндпоинту во время технического обслуживания. Поскольку один лог-файл может превышать несколько гигабайт, загрузка его в память через readFileSync нецелесообразна. Потоки readline в Node.js обрабатывают файл построчно с постоянным расходом памяти, что позволяет сканировать произвольно большие логи на обычном ноутбуке разработчика.

Проблему «файл слишком велик для памяти» с отдельными JWT вы не встретите, поскольку один токен редко превышает несколько килобайт. Ситуация, которая действительно возникает, — это сканирование большого лога доступа или журнала аудита для поиска JWT-токенов, декодирования каждого из них и извлечения конкретных утверждений. Потоки Node.js справляются с этим без загрузки всего файла.

Node.js — потоковое чтение NDJSON-лога с декодированием встроенных JWT
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

async function scanLogsForExpiredTokens(logPath) {
  const fileStream = createReadStream(logPath, { encoding: "utf-8" });
  const rl = createInterface({ input: fileStream, crlfDelay: Infinity });

  let lineCount = 0;
  let expiredCount = 0;
  const nowSeconds = Math.floor(Date.now() / 1000);

  for await (const line of rl) {
    lineCount++;
    try {
      const entry = JSON.parse(line);
      if (!entry.authorization_token) continue;

      const segments = entry.authorization_token.split(".");
      if (segments.length !== 3) continue;

      const payload = JSON.parse(
        Buffer.from(segments[1], "base64url").toString("utf-8")
      );

      if (payload.exp && payload.exp < nowSeconds) {
        expiredCount++;
        const expDate = new Date(payload.exp * 1000).toISOString();
        console.log("Line " + lineCount + ": expired token for " + payload.sub + ", exp=" + expDate);
      }
    } catch {
      // Skip malformed lines
    }
  }

  console.log(`\nScanned ${lineCount} lines, found ${expiredCount} expired tokens`);
}

scanLogsForExpiredTokens("./logs/api-access-2026-03.ndjson");
Node.js — извлечение уникальных субъектов JWT из потока логов
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

async function extractUniqueSubjects(logPath) {
  const rl = createInterface({
    input: createReadStream(logPath, { encoding: "utf-8" }),
    crlfDelay: Infinity,
  });

  const subjects = new Set();
  const jwtRegex = /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;

  for await (const line of rl) {
    const matches = line.match(jwtRegex);
    if (!matches) continue;

    for (const token of matches) {
      try {
        const payload = JSON.parse(
          Buffer.from(token.split(".")[1], "base64url").toString("utf-8")
        );
        if (payload.sub) subjects.add(payload.sub);
      } catch {
        // Not a valid JWT
      }
    }
  }

  console.log(`Found ${subjects.size} unique subjects:`);
  for (const sub of subjects) console.log(`  ${sub}`);
}

extractUniqueSubjects("./logs/gateway-2026-03.log");
Примечание:Переключайтесь на потоковое чтение, когда лог-файл превышает 50 МБ. Загрузка 500 МБ NDJSON-файла через readFileSync заблокирует память и вызовет паузы GC. Подход с readline обрабатывает по одной строке с постоянным использованием памяти.

Типичные ошибки

Использование atob() без TextDecoder для не-ASCII утверждений

Проблема: atob() возвращает строку Latin-1. Многобайтовые UTF-8 символы (эмодзи, кириллица, символы с диакритикой) разбиваются на отдельные символы и выглядят искажёнными.

Решение: Преобразуйте результат atob() в Uint8Array, затем передайте его через new TextDecoder('utf-8').

Before · JavaScript
After · JavaScript
// Ломается на не-ASCII утверждениях
const payload = JSON.parse(atob(token.split(".")[1]));
// display_name отображается как "ÐлекÑей" вместо "Алексей Иванов"
const binary = atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"));
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const payload = JSON.parse(new TextDecoder("utf-8").decode(bytes));
// display_name корректно отображает "Алексей Иванов"
Забытая замена символов base64url на base64

Проблема: atob() выбрасывает "InvalidCharacterError", потому что base64url использует - и _ вместо + и /.

Решение: Замените - на + и _ на / перед вызовом atob(). Node.js Buffer.from() с 'base64url' делает это автоматически.

Before · JavaScript
After · JavaScript
// Выбрасывает: InvalidCharacterError: String contains an invalid character
const payload = atob(token.split(".")[1]);
const segment = token.split(".")[1];
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const payload = atob(base64); // теперь работает
Доверие декодированным утверждениям JWT без верификации подписи

Проблема: Любой может создать JWT с произвольной полезной нагрузкой. Декодирование только читает данные — оно не доказывает, что токен был выдан вашим сервером аутентификации.

Решение: На стороне сервера всегда верифицируйте подпись с помощью jose.jwtVerify() или jsonwebtoken.verify(). Декодирование без верификации допустимо только для отображения пользовательских утверждений на стороне клиента.

Before · JavaScript
After · JavaScript
// ОПАСНО: декодировано, но не верифицировано
const claims = JSON.parse(atob(token.split(".")[1]));
if (claims.role === "admin") {
  grantAdminAccess(); // злоумышленник может подделать это
}
import * as jose from "jose";
const { payload } = await jose.jwtVerify(token, secretKey);
if (payload.role === "admin") {
  grantAdminAccess(); // безопасно — подпись проверена
}
Сравнение exp с Date.now() без деления на 1000

Проблема: JWT exp задаётся в секундах от начала эпохи, а Date.now() возвращает миллисекунды. Сравнение всегда будет говорить, что токен действителен, так как миллисекундная временная метка в 1000 раз больше.

Решение: Разделите Date.now() на 1000 и округлите вниз перед сравнением с exp.

Before · JavaScript
After · JavaScript
// Ошибка: Date.now() в миллисекундах, exp в секундах
if (payload.exp > Date.now()) {
  console.log("Token is valid"); // всегда true — неверно!
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp > nowSeconds) {
  console.log("Token is valid"); // корректное сравнение
}

Методы декодирования JWT — краткое сравнение

Метод
Среда
Безопасен для UTF-8
Проверка подписи
Произвольные типы
Требует установки
atob() + TextDecoder
Браузер
Н/Д (только чтение)
Нет
Buffer.from()
Node.js
Н/Д (только чтение)
Нет
decodeURIComponent()
Браузер (устаревший)
Н/Д (только чтение)
Нет
jose
Оба
✓ (JWS/JWE)
npm install
jsonwebtoken
Node.js
npm install
jwt-decode
Оба
Н/Д
npm install

Используйте atob() + TextDecoder для декодирования на стороне браузера, когда нужно только отобразить утверждения пользователю. Используйте Buffer.from() в скриптах Node.js и CLI-инструментах. Обращайтесь к jose как только вам нужно верифицировать подпись — то есть в любом серверном auth-middleware. Пакет jwt-decode — лёгкая альтернатива, если нужен однофункциональный API для декодирования без верификации в браузере. Для быстрого визуального просмотра без написания кода вставьте ваш токен в инструмент JWT Decoder.

Часто задаваемые вопросы

Как декодировать JWT-токен в JavaScript без библиотеки?

Разбейте токен по символу ".", возьмите второй сегмент (полезная нагрузка), нормализуйте кодировку base64url, заменив - на + и _ на /, дополните символами =, затем вызовите atob() и TextDecoder, чтобы получить JSON-строку в UTF-8. Передайте результат в JSON.parse() — и вы получите объект с утверждениями. Пакеты npm не нужны. Этот подход работает во всех современных браузерах и в Node.js 18+. Если нужно прочитать и заголовок, примените те же шаги декодирования к первому сегменту. Имейте в виду, что это даёт вам исходные данные без проверки подписи — воспринимайте результат только как отображение, пока не убедитесь в подписи на сервере.

JavaScript
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const payload = token.split(".")[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = atob(base64);
const claims = JSON.parse(json);
console.log(claims);
// { sub: "usr_921f", role: "admin" }

В чём разница между atob() и Buffer.from() при декодировании JWT?

atob() — это браузерный API, который декодирует стандартный Base64 в бинарную строку Latin-1. Он не понимает кодировку base64url напрямую, поэтому нужно сначала заменить символы - и _. Buffer.from(segment, "base64url") — это API Node.js, который нативно обрабатывает алфавит base64url и возвращает Buffer, у которого можно вызвать .toString("utf-8"). Используйте atob() в браузере, Buffer.from() — в Node.js. Третий вариант — приём с процентным кодированием через decodeURIComponent — работает медленнее и исторически был распространён, но он опирается на устаревшую функцию escape() в некоторых старых фрагментах кода; в новом коде его следует избегать. Для изоморфного кода, работающего в обоих окружениях, проверьте typeof Buffer !== "undefined" и выберите ветку соответственно.

JavaScript
// Браузер
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));

// Node.js
const json2 = Buffer.from(payload, "base64url").toString("utf-8");

Почему atob() ломается на не-ASCII-утверждениях JWT?

atob() возвращает строку Latin-1, где каждый символ соответствует одному байту. Многобайтовые последовательности UTF-8 (эмодзи, символы CJK, буквы с диакритикой выше Latin-1) разбиваются на несколько символов, что приводит к искажённому выводу. Решение — сначала преобразовать бинарную строку в Uint8Array, затем передать этот массив в new TextDecoder("utf-8").decode(). API TextDecoder правильно собирает многобайтовые последовательности. Эту проблему легко не заметить при разработке, поскольку большинство полезных нагрузок JWT содержат только ASCII-поля: идентификаторы пользователей, временные метки и названия ролей. Ошибка проявляется только тогда, когда утверждение содержит не-ASCII отображаемое имя или локализованную строку. Всегда используйте путь через TextDecoder в новом коде, даже если ваши текущие полезные нагрузки — только ASCII, поскольку утверждения могут измениться по мере развития приложения.

JavaScript
// Сломанный вариант: atob возвращает Latin-1, многобайтовые символы искажены
const broken = atob(base64); // "ð\x9F\x8E\x89" вместо эмодзи

// Исправленный вариант: преобразование в байтовый массив, затем TextDecoder
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const fixed = new TextDecoder("utf-8").decode(bytes);

Можно ли проверить подпись JWT в JavaScript?

Декодирование и верификация — это разные операции. Декодирование просто читает полезную нагрузку, которая не зашифрована. Верификация проверяет подпись по секрету (HMAC) или открытому ключу (RSA/ECDSA). Библиотека jose поддерживает оба варианта: в браузере через Web Crypto API и в Node.js. Пакет jsonwebtoken работает только в Node.js. Никогда не доверяйте декодированным утверждениям без проверки подписи на стороне сервера. На стороне клиента допустимо декодировать JWT для чтения отображаемого имени пользователя или времени истечения срока действия, но любое решение о доступе — например, проверка роли или разрешения пользователя — должно происходить в серверном коде после верификации. Злоумышленник, понимающий формат JWT, может создать токен с произвольными утверждениями, и ваша клиентская проверка пройдёт.

JavaScript
import * as jose from "jose";

const secret = new TextEncoder().encode("your-256-bit-secret");
const { payload } = await jose.jwtVerify(token, secret);
console.log(payload.sub); // verified claims

Как проверить, истёк ли JWT в JavaScript?

Декодируйте полезную нагрузку и прочитайте утверждение exp, которое является Unix-временной меткой в секундах. Сравните его с текущим временем с помощью Math.floor(Date.now() / 1000). Если текущее время больше exp, токен истёк. Помните: значение exp задаётся в секундах от начала эпохи, а не в миллисекундах, поэтому необходимо делить Date.now() на 1000. На практике стоит добавить небольшой буфер для расхождения часов — проверять, не истекает ли токен в течение следующих 30 секунд, а не строго в прошлом. Это предотвращает пограничные случаи, когда токен ещё технически действителен в момент декодирования, но истекает к моменту обработки следующего вызова API. Также обрабатывайте случай, когда exp отсутствует, — это означает, что токен никогда не истекает.

JavaScript
function isTokenExpired(token) {
  const payload = JSON.parse(
    atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
  );
  const nowSeconds = Math.floor(Date.now() / 1000);
  return payload.exp < nowSeconds;
}

console.log(isTokenExpired(myToken)); // true or false

Как написать изоморфный код декодирования JWT, работающий и в Node.js, и в браузере?

Проверьте наличие globalThis.Buffer. Если оно существует, вы в Node.js и можете использовать Buffer.from(segment, "base64url").toString("utf-8"). Если нет — вы в браузере и должны использовать atob() с подходом TextDecoder. Оберните эту проверку в единственную функцию decodeBase64Url и используйте её везде. Это особенно важно для вспомогательных пакетов, компонентов дизайн-системы и любого общего кода в монорепозитории, который импортируется как серверным компонентом Next.js, так и браузерным компонентом React. Хранение логики определения окружения в одном месте означает, что при изменении среды выполнения — например, когда Deno добавит полную поддержку Buffer или новая edge-среда потребует другого пути — достаточно обновить только одно место.

JavaScript
function decodeBase64Url(segment) {
  if (typeof Buffer !== "undefined") {
    return Buffer.from(segment, "base64url").toString("utf-8");
  }
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
  return new TextDecoder("utf-8").decode(bytes);
}

Связанные инструменты

Также доступно на:Python
MW
Marcus WebbJavaScript Performance Engineer

Marcus specialises in JavaScript performance, build tooling, and the inner workings of the V8 engine. He has spent years profiling and optimising React applications, working on bundler configurations, and squeezing every millisecond out of critical rendering paths. He writes about Core Web Vitals, JavaScript memory management, and the tools developers reach for when performance really matters.

SL
Sophie LaurentТехнический рецензент

Sophie is a full-stack developer focused on TypeScript across the entire stack — from React frontends to Express and Fastify backends. She has a particular interest in type-safe API design, runtime validation, and the patterns that make large JavaScript codebases stay manageable. She writes about TypeScript idioms, Node.js internals, and the ever-evolving JavaScript module ecosystem.