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 text decoder для JWT: розбиття токена, нормалізація base64url до стандартного Base64, atob() та TextDecoder для коректної обробки UTF-8, Node.js Buffer.from(), перевірка підпису за допомогою jose, та типові помилки, на яких щодня спотикаються розробники. Для швидкого одноразового перегляду скористайтеся онлайн JWT Decoder замість цього. Усі приклади орієнтовані на 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 (суб'єкт — зазвичай ID користувача), 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
// Заголовок
{ "alg": "HS256" }

// Корисне навантаження
{
  "sub": "usr_921f",
  "role": "admin",
  "iat": 1711610000
}

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

Нативний браузерний конвеєр для декодування JWT складається з чотирьох кроків. По-перше, розбийте рядок токена по символу "." для отримання трьох сегментів. По-друге, нормалізуйте сегмент base64url, замінивши - на + і _ на /, а потім доповнивши символами = до кратності 4. По-третє, викличте atob() для декодування Base64 у двійковий рядок. По-четверте, перетворіть двійковий рядок на коректний UTF-8 за допомогою TextDecoder. Останній крок важливий, оскільки atob() повертає Latin-1. Багатобайтові символи — emoji, кириличний текст, символи з наголосами вище діапазону Latin-1 — без кроку JavaScript text decoder виходять спотвореними.

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 розбиті на кілька символів. Замість emoji або символів кирилиці ви побачите сміттєві дані. Завжди передавайте через new TextDecoder("utf-8") після atob().

Декодування UTF-8 JWT клеймів із багатобайтовими символами

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

JavaScript — повний цикл UTF-8 з кириличним ім'ям у JWT корисному навантаженні
// Симуляція JWT корисного навантаження з кириличними символами
const payloadObj = {
  sub: "usr_e821",
  display_name: "Олексій Коваленко",
  team: "Platform 🚀",
  region: "ua-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 у поєднанні з трюком percent-кодування. Цей підхід JavaScript decodeURIComponent працює, оскільки він повторно кодує кожен байт як шістнадцяткову пару з відсотком, а потім 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));
Попередження:Ви можете зустріти застарілий шаблон decodeURIComponent(escape(atob(segment))) у старих фрагментах утиліт JWT. Функція escape() є застарілою та нестандартною. Замініть її підходом через TextDecoder, показаним вище. Шаблон JavaScript unescape decoder має ту саму проблему: unescape() є застарілою. Обидві функції можуть бути видалені з майбутніх рушіїв JavaScript.

Конвеєр декодування JWT — довідник кроків

Кожен крок нативного браузерного конвеєра декодування JWT із використовуваним JavaScript API і тим, що він виробляє:

Параметр / Крок
Тип
Опис
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 string decoder для серверного коду. Один рядок перетворює сегмент 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"]
// }

У продакшн Node.js-сервісах шаблон декодування Buffer.from() зустрічається в трьох повторюваних місцях. Перше — middleware для логування запитів: ви декодуєте вхідний заголовок Authorization для прикріплення userId та org до кожного структурованого запису журналу без додаткового мережевого звернення до сервера аутентифікації. Друге — налагодження: ви виводите в консоль декодовані клейми токена під час розробки, щоб підтвердити правильність виданих областей дії перед написанням тестових тверджень. Третє — превентивне оновлення токена в API-шлюзах. Замість того, щоб пересилати токен нагору і чекати, поки downstream-сервіс поверне 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 — декодування JWT засобами bash без 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 .

Для швидкого візуального огляду без будь-якого термінала вставте ваш токен у JWT Decoder від ToolDeck для порівняльного розбору всіх трьох сегментів із кольоровими позначками клеймів і статусом закінчення дії.

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

Для продакшн 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. Якщо ваша логіка аутентифікації знаходиться у файлі middleware Next.js (який працює у Edge Runtime), або у Cloudflare Worker, або у спільній утиліті, що імпортується як серверним, так і клієнтським кодом, jose — правильний вибір, оскільки не має нативних залежностей і встановлюється без кроку збирання. jsonwebtoken залишається доцільним для чистих серверних Node.js-застосунків, де потрібна його ширша екосистема помічників для підпису і де немає планів запускати код у edge-середовищі. У новому проєкті у 2026 році використовуйте jose за замовчуванням, якщо немає конкретних причин надавати перевагу старішому API.

Якщо потрібне лише декодування без верифікації, jose надає jose.decodeJwt(token) що повертає корисне навантаження, і jose.decodeProtectedHeader(token) для заголовка. Це зручні функції, що виконують Base64url-декодування внутрішньо. Але вся причина звертатися до jose полягає в тому, що рідко варто декодувати без верифікації. Якщо ви на клієнтській стороні і просто потрібно показати відображуване ім'я користувача або URL аватара з клеймів токена, декодування без верифікації допустиме. На серверній стороні завжди верифікуйте. Я бачив продакшн-системи, що декодували 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]);
// Запуск: 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 {
      // Пропуск некоректних рядків
    }
  }

  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 {
        // Не є коректним 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 символи (emoji, кирилиця, символи з наголосами) розбиваються на кілька символів і виходять спотвореними.

Рішення: Перетворіть вивід 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(). Buffer.from() у Node.js із '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 як тільки потрібно верифікувати підпис — тобто в будь-якому серверному middleware аутентифікації. Пакет jwt-decode — легка альтернатива, якщо потрібен API з однією функцією для декодування без верифікації у браузері. Для швидкого візуального огляду без написання коду вставте ваш токен у інструмент JWT Decoder.

Часті запитання

Як декодувати JWT-токен у JavaScript без бібліотеки?

Розбийте токен по символу ".", візьміть другий сегмент (корисне навантаження), нормалізуйте кодування base64url, замінивши - на + та _ на /, доповніть символами =, а потім викличте atob() і TextDecoder, щоб отримати рядок UTF-8 JSON. Передайте результат через 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. Третій варіант — трюк із percent-кодуванням через 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?

atob() повертає рядок Latin-1, де кожен символ відповідає одному байту. Багатобайтові послідовності UTF-8 (emoji, символи кирилиці, діакритичні знаки вище 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" замість emoji

// Правильно: перетворення на масив байтів, потім 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-runtime потребуватиме іншого шляху коду.

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.