JWT Decoder JavaScript — atob(), TextDecoder & jose

·JavaScript Performance Engineer·Đánh giá bởiSophie Laurent·Đã xuất bản

Sử dụng Giải mã JWT miễn phí trực tiếp trên trình duyệt — không cần cài đặt.

Dùng thử Giải mã JWT trực tuyến →

Mọi luồng xác thực tôi đã xây dựng đều đến cùng một điểm: bạn có một JWT nằm trong cookie, header, hoặc URL callback OAuth, và bạn cần đọc nội dung bên trong. Một JWT decoder trong JavaScript không cần bất kỳ npm package nào. Header và payload của token chỉ là JSON được mã hóa Base64url, và cả trình duyệt lẫn Node.js đều có sẵn mọi thứ cần thiết để giải mã chúng. Hướng dẫn này bao gồm toàn bộ pipeline JavaScript text decoder cho JWT: tách token, chuẩn hóa base64url sang Base64 chuẩn, atob() TextDecoder để xử lý UTF-8 đúng cách, Node.js Buffer.from(), xác minh chữ ký với jose, và các lỗi phổ biến khiến các lập trình viên gặp khó khăn hàng ngày. Để kiểm tra nhanh một lần, hãy thử JWT Decoder trực tuyến thay thế. Tất cả ví dụ nhắm đến ES2020+ Node.js 18+.

  • Tách JWT theo dấu "." — chỉ số 0 là header, chỉ số 1 là payload, chỉ số 2 là chữ ký.
  • atob() giải mã Base64 nhưng trả về Latin-1, không phải UTF-8. Dùng TextDecoder hoặc Buffer.from() cho các claims không phải ASCII.
  • Buffer.from(segment, "base64url") xử lý base64url tự nhiên trong Node.js — không cần thay thế ký tự thủ công.
  • Giải mã KHÔNG phải là xác minh. Không bao giờ tin tưởng claims từ JWT đã giải mã mà không kiểm tra chữ ký phía server.
  • Thư viện jose làm cả hai: xác minh chữ ký HS256/RS256/ES256 và trả về payload đã giải mã trong một lần gọi.

JWT Decoding là gì?

JSON Web Token là ba phân đoạn được mã hóa Base64url ngăn cách bởi dấu chấm. Phân đoạn đầu tiên là header, phân đoạn thứ hai là payload (các claims mà bạn thực sự quan tâm), và phân đoạn thứ ba là chữ ký mật mã. Header là một đối tượng JSON nhỏ mô tả token. Trường quan trọng nhất của nó là alg — thuật toán ký (ví dụ: HS256, RS256, ES256). Trường typ hầu như luôn là "JWT", và trường tùy chọn kid xác định khóa nào đã được dùng để ký token — quan trọng khi nhà cung cấp danh tính xoay vòng khóa và công bố JWKS endpoint với nhiều khóa công khai.

Payload chứa các claims. RFC 7519 định nghĩa bảy tên claim đã đăng ký: sub (subject — thường là user ID), iss (issuer — URL auth server), aud (audience — API mà token dành cho), iat (timestamp phát hành), exp (timestamp hết hạn), nbf (timestamp not-before), và jti (JWT ID — dùng để ngăn replay attack). Tất cả timestamps là Unix epoch tính bằng giây, không phải milliseconds. Phân đoạn chữ ký là nhị phân thô — HMAC digest có khóa hoặc chữ ký số bất đối xứng. Nó được mã hóa Base64url như các phân đoạn khác, nhưng các byte của nó không phải JSON và không có cấu trúc có thể đọc được.

Trong thực tế, bạn giải mã JWT trong JavaScript vì ba lý do phổ biến. Đầu tiên, gỡ lỗi: bạn có token từ luồng OAuth hoặc môi trường test và muốn xác nhận các claims khớp với những gì auth server nên đã phát hành. Thứ hai, đọc claims người dùng để hiển thị ở phía client — hiển thị tên người dùng đã đăng nhập, URL avatar, hoặc role badge từ token payload mà không cần thêm API call. Thứ ba, kiểm tra hết hạn trước khi thử refresh: nếu exp nằm trong 60 giây tới, kích hoạt silent refresh trước API call tiếp theo thay vì chờ phản hồi 401.

Giải mã không kiểm tra token có hợp lệ hay bị giả mạo không. Đó là thao tác riêng biệt gọi là xác minh, yêu cầu bí mật HMAC hoặc khóa công khai RSA/ECDSA. Bất kỳ ai cũng có thể giải mã JWT. Chỉ người nắm giữ khóa đúng mới có thể xác minh. Sự phân biệt này làm nhiều lập trình viên nhầm lẫn, đặc biệt khi xây dựng luồng auth phía client nơi claims đã giải mã được hiển thị nhưng không bao giờ được tin tưởng cho các quyết định phân quyền mà không có kiểm tra backend đã xác minh.

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

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

atob() + TextDecoder — Giải mã JWT tích hợp sẵn trong trình duyệt

Pipeline tích hợp sẵn trong trình duyệt để giải mã JWT có bốn bước. Đầu tiên, tách chuỗi token theo "." để lấy ba phân đoạn. Thứ hai, chuẩn hóa phân đoạn base64url bằng cách thay thế - bằng + _ bằng /, rồi thêm đệm = cho đến khi độ dài chia hết cho 4. Thứ ba, gọi atob() để giải mã Base64 thành chuỗi nhị phân. Thứ tư, chuyển đổi chuỗi nhị phân thành UTF-8 đúng định dạng bằng TextDecoder. Bước cuối đó quan trọng vì atob() trả về Latin-1. Các ký tự nhiều byte — emoji, chữ CJK, ký tự có dấu ngoài phạm vi Latin-1 — sẽ bị hiển thị sai nếu không có bước JavaScript text decoder.

JavaScript — giải mã JWT tối giản
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 }

Bước thêm đệm dễ bị bỏ qua. JWT loại bỏ các ký tự = phía sau khỏi các phân đoạn Base64url vì đặc tả JWT (RFC 7515) định nghĩa base64url không có đệm. Nhưng atob() trong một số engine trình duyệt ném ra InvalidCharacterError nếu độ dài đầu vào không chia hết cho 4. Thêm đệm phòng thủ bằng padEnd() tránh được edge case đó trên tất cả các môi trường. Đây là phiên bản có thể tái sử dụng giải mã cả header và payload thành các đối tượng riêng biệt:

JavaScript — giải mã header và payload
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"

Sau khi có hai hàm này, đáng để đặt chúng trong một utility module chung thay vì copy-paste logic qua nhiều file. Một file src/lib/jwt.ts hoặc utils/jwt-decode.ts với kiểu trả về rõ ràng làm rõ ý định trên toàn codebase. Trong TypeScript, bạn có thể định kiểu trả về là { header: JwtHeader; payload: JwtPayload } trong đó JwtHeader bao gồm alg, typ, và tùy chọn kid, và JwtPayload mở rộng các claims đã đăng ký theo RFC 7519 với index signature cho claims tùy chỉnh. Tập trung logic giải mã có nghĩa là khi bạn muốn thêm xử lý lỗi (bắt các phân đoạn bị lỗi) hoặc telemetry (ghi log các lỗi giải mã), bạn chỉ có một nơi để cập nhật.

Lưu ý:Bước TextDecoder là điều làm cho pipeline này an toàn với các claims không phải ASCII. Nếu không có nó, atob() trả về chuỗi Latin-1 trong đó các chuỗi UTF-8 nhiều byte bị chia nhỏ qua các ký tự. Bạn sẽ thấy ký tự lạ thay vì emoji hay chữ CJK. Luôn đưa qua new TextDecoder("utf-8") sau atob().

Giải mã JWT Claims UTF-8 với ký tự nhiều byte

JWT payload là JSON UTF-8 được mã hóa dưới dạng base64url. Hầu hết payload chứa các trường chỉ ASCII như user ID và timestamps, vì vậy các lập trình viên không bao giờ nhận ra rằng atob() trả về Latin-1 thay vì UTF-8. Vấn đề xuất hiện ngay khi một claim chứa emoji, ký tự tiếng Nhật, Cyrillic, hoặc bất kỳ code point nào trên U+00FF. Mẫu JavaScript decode UTF-8 yêu cầu chuyển đổi chuỗi nhị phân thành mảng byte trước, rồi chạy nó qua TextDecoder.

JavaScript — vòng lặp UTF-8 với emoji trong JWT payload
// Mô phỏng JWT payload với emoji và ký tự CJK
const payloadObj = {
  sub: "usr_e821",
  display_name: "田中太郎",
  team: "Platform 🚀",
  region: "ap-northeast-1"
};

// Mã hóa: object → JSON → bytes 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(/=+$/, "");

// Giải mã: base64url → base64 → chuỗi nhị phân → bytes → chuỗi 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); // "田中太郎" — đúng
console.log(result.team);         // "Platform 🚀" — đúng

Có một mẫu fallback legacy mà bạn sẽ thấy trong các codebase cũ sử dụng decodeURIComponent kết hợp với thủ thuật mã hóa phần trăm. Cách tiếp cận JavaScript decodeURIComponent này hoạt động vì nó mã hóa lại mỗi byte dưới dạng cặp percent-hex, rồi decodeURIComponent tái tạo các chuỗi UTF-8 nhiều byte:

JavaScript — decodeURIComponent fallback cho UTF-8
function decodeBase64UrlLegacy(segment) {
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const binary = atob(base64);
  // Chuyển mỗi ký tự thành %XX hex, rồi decodeURIComponent tái tạo UTF-8
  const utf8 = decodeURIComponent(
    binary.split("").map(c =>
      "%" + c.charCodeAt(0).toString(16).padStart(2, "0")
    ).join("")
  );
  return utf8;
}

// Hoạt động với claims không phải ASCII mà không cần TextDecoder
const payload = decodeBase64UrlLegacy(token.split(".")[1]);
console.log(JSON.parse(payload));
Cảnh báo:Bạn có thể gặp mẫu legacy decodeURIComponent(escape(atob(segment))) trong các đoạn JWT utility cũ. Hàm escape() đã bị deprecated và không chuẩn. Thay thế bằng phương pháp TextDecoder được hiển thị ở trên. Mẫu JavaScript unescape decoder có cùng vấn đề: unescape() đã bị deprecated. Cả hai hàm có thể bị xóa khỏi các JavaScript engine trong tương lai.

Pipeline Giải mã JWT — Tham chiếu từng bước

Mỗi bước trong pipeline giải mã JWT tích hợp sẵn trình duyệt, với JavaScript API được dùng và những gì nó tạo ra:

Tham số / Bước
Kiểu
Mô tả
token.split(".")
string[]
Tách JWT thành các phân đoạn [header, payload, signature]
base64url → base64
string replace
Thay - bằng +, _ bằng /, thêm đệm = để độ dài chia hết cho 4
atob(base64)
string
Giải mã chuỗi Base64 chuẩn thành chuỗi nhị phân (Latin-1)
TextDecoder("utf-8")
TextDecoder
Chuyển đổi Uint8Array các byte thô thành chuỗi UTF-8 đúng định dạng
JSON.parse()
object
Phân tích chuỗi JSON thành đối tượng JavaScript

Tương đương Node.js gộp các bước 2 đến 4 thành một lần gọi: Buffer.from(segment, "base64url").toString("utf-8"). Tùy chọn mã hóa "base64url" xử lý việc chuyển đổi bảng chữ cái và thêm đệm nội bộ.

Buffer.from() — String Decoder Node.js cho JWT

Node.js có con đường đơn giản hơn nhiều. Class Buffer chấp nhận mã hóa "base64url" trực tiếp, vì vậy bạn bỏ qua việc thay thế ký tự thủ công và thêm đệm. Đây là con đường JavaScript string decoder cho code phía server. Một dòng chuyển phân đoạn JWT thành chuỗi UTF-8, và nó xử lý ký tự nhiều byte đúng cách mà không cần bước bổ sung nào.

Node.js 18+ — giải mã JWT với 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 }

Đây là cách tiếp cận tôi dùng trong mọi dự án Node.js. Nó ngắn hơn, nhanh hơn, và đã xử lý UTF-8 đúng cách. Không cần TextDecoder, không cần thay thế ký tự, không cần tính toán đệm. Class Buffer là JavaScript string decoder xử lý bảng chữ cái base64url tự nhiên, điều này loại bỏ toàn bộ lớp lỗi liên quan đến thay thế ký tự. Nếu code của bạn cần chạy trên cả trình duyệt lẫn Node.js, xem FAQ ở dưới để biết về một hàm wrapper isomorphic phát hiện môi trường lúc runtime.

Đây là ví dụ đầy đủ hơn cho thấy cách trích xuất các JWT claims phổ biến và chuyển đổi timestamps thành ngày đọc được, đây là mẫu bạn sẽ dùng nhiều nhất trong middleware và API route handler:

Node.js — trích xuất JWT claim thực tế
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"]
// }

Trong các dịch vụ Node.js sản xuất, mẫu giải mã Buffer.from() xuất hiện ở ba nơi thường xuyên. Thứ nhất là middleware ghi log yêu cầu: bạn giải mã header Authorization đến để đính kèm userId org vào mỗi log entry có cấu trúc mà không cần round-trip mạng thêm đến auth server. Thứ hai là gỡ lỗi: bạn in các claims token đã giải mã ra console trong quá trình phát triển để xác nhận các scope đúng đã được phát hành trước khi viết test assertion. Thứ ba là proactive token refresh trong API gateway. Thay vì chuyển tiếp token lên upstream và để downstream service trả về 401 khi token hết hạn giữa chừng, gateway giải mã token ở edge, đọc claim exp, và kích hoạt refresh nếu hết hạn trong 30 giây tới. Điều này loại bỏ một lớp lỗi xác thực thoáng qua khó tái tạo và gây bực bội khi gỡ lỗi.

Lưu ý:Mã hóa "base64url" được thêm vào trong Node.js 15.7.0. Nếu bạn đang dùng Node.js 14 hoặc cũ hơn, hãy dùng fallback Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64") hoạt động tương tự nhưng yêu cầu hoán đổi ký tự thủ công.

Giải mã JWT từ File và API Response

Hai tình huống xuất hiện liên tục. Đầu tiên là đọc JWT từ file cục bộ: một token đã lưu trong quá trình phát triển, test fixture, hoặc file được dump trong một sự cố để phân tích sau. Thứ hai là trích xuất JWT từ HTTP response, thường là trường access_token trong body OAuth token response hoặc header Authorization. Cả hai cần xử lý lỗi vì token bị lỗi, file bị cắt ngắn, và lỗi mạng là thực tế hàng ngày. Token hợp lệ tuần trước có thể có khoảng trắng hoặc ký tự xuống dòng từ copy-paste. Response body có thể là HTML thay vì JSON nếu auth server trả về trang lỗi.

Đọc JWT từ File (Node.js)

Node.js — giải mã JWT từ file với xử lý lỗi
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);
}

Trích xuất JWT từ API Response (fetch)

JavaScript — giải mã JWT từ API response
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);
}

// Cách dùng
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);
}

Giải mã JWT trên Command-Line

Đôi khi bạn chỉ muốn xem nhanh token từ terminal mà không cần viết script. Node.js có sẵn trên hầu hết máy lập trình viên, vì vậy one-liner hoạt động tốt. jq xử lý việc định dạng đẹp.

bash — giải mã JWT payload từ terminal
# Giải mã JWT payload bằng Node.js one-liner
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'))))"

# Pipe đến jq để đầu ra đẹp hơn
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 .

# Giải mã cả header và payload
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()));
  });
"

Nếu bạn thích bash thuần mà không có Node.js, hãy pipe phân đoạn qua base64 -d sau khi sửa các ký tự base64url bằng tr:

bash — giải mã JWT bằng bash thuần không có Node.js
# Bash thuần: giải mã JWT payload không cần Node.js
echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

# Biến thể macOS (base64 -D thay vì -d)
echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -D 2>/dev/null | jq .

Để kiểm tra trực quan nhanh mà không cần terminal, hãy dán token vào ToolDeck JWT Decoder để có bảng phân tích cạnh nhau của cả ba phân đoạn với nhãn claim có màu và trạng thái hết hạn.

jose — Xác minh và giải mã trong một thư viện

Đối với middleware xác thực sản xuất, bạn cần xác minh chữ ký, không chỉ giải mã. Thư viện jose là lựa chọn tốt nhất. Nó hoạt động trên cả Node.js lẫn trình duyệt (qua Web Crypto API), hỗ trợ HS256, RS256, ES256, EdDSA, và JWE (token được mã hóa), và không có native dependency. Cài đặt bằng npm install jose.

JavaScript — jose: xác minh token 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: xác minh RS256 với JWKS endpoint
import * as jose from "jose";

// Lấy bộ khóa công khai từ identity provider
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, v.v. đã được xác minh
  req.userId = payload.sub;
} catch (err) {
  return res.status(401).json({ error: "Invalid token" });
}

Khi quyết định giữa jose và package jsonwebtoken cũ hơn, sự khác biệt chính là phạm vi runtime. jsonwebtoken chỉ dành cho Node.js — nó phụ thuộc vào built-in crypto và sẽ không bundle cho trình duyệt. jose hoàn toàn isomorphic: nó sử dụng Web Crypto API, có sẵn trong tất cả trình duyệt hiện đại, Node.js 16+, Deno, Bun, và Cloudflare Workers. Nếu logic xác thực của bạn nằm trong file middleware Next.js (chạy trong Edge Runtime), hoặc trong Cloudflare Worker, hoặc trong utility chung được import bởi cả code server lẫn client, jose là lựa chọn đúng vì nó không có native dependency và cài đặt mà không cần bước build. jsonwebtoken vẫn hợp lý cho các ứng dụng server Node.js thuần khi bạn cần hệ sinh thái rộng hơn của signing helper và không có kế hoạch chạy code trong môi trường edge. Trong dự án mới từ đầu vào năm 2026, mặc định chọn jose trừ khi bạn có lý do cụ thể để thích API cũ hơn.

Nếu bạn chỉ cần giải mã mà không có xác minh, jose cung cấp jose.decodeJwt(token) trả về payload và jose.decodeProtectedHeader(token) cho header. Đây là các hàm tiện lợi thực hiện Base64url decoding nội bộ. Nhưng lý do để dùng jose là bạn hiếm khi nên giải mã mà không xác minh. Nếu bạn đang ở phía client và chỉ cần hiển thị cho người dùng tên hiển thị hay URL avatar từ claims token, chỉ giải mã là ổn. Ở phía server, luôn xác minh. Tôi đã thấy các hệ thống sản xuất giải mã JWT claims cho các quyết định kiểm soát truy cập mà không kiểm tra chữ ký, và đó là cửa mở cho bất kỳ kẻ tấn công nào hiểu định dạng JWT.

JavaScript — jose.decodeJwt cho các tình huống chỉ giải mã
import * as jose from "jose";

// Chỉ giải mã: không cần bí mật, không cần xác minh
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"

// Kiểm tra hết hạn mà không cần xác minh (hiển thị phía client)
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
  console.log("Token has expired — redirect to login");
}

Đầu ra Terminal với tô sáng cú pháp

Khi gỡ lỗi JWT token trong Node.js CLI tool hoặc trong sự cố, đầu ra có màu tạo ra sự khác biệt thực sự. Thư viện chalk kết hợp với JSON.stringify làm được việc. Cài đặt bằng npm install chalk.

Node.js — đầu ra giải mã JWT có màu
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)));

  // Tô sáng trạng thái hết hạn
  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..."
Lưu ý:Đầu ra có màu chỉ dành cho terminal. Không dùng chalk khi viết JWT claims vào log file, API response, hoặc database field. Các mã escape ANSI sẽ xuất hiện như ký tự lạ trong các ngữ cảnh không phải terminal.

Xử lý JWT từ file log lớn

Cơ sở hạ tầng API hiện đại phát ra access log có cấu trúc ở định dạng NDJSON — một đối tượng JSON mỗi dòng, với mỗi dòng chứa đường dẫn yêu cầu, trạng thái response, độ trễ, và header Authorization đã giải mã hoặc thô. Trong dịch vụ bận rộn, các file này phát triển nhanh chóng: gateway xử lý 10.000 yêu cầu mỗi phút tạo ra hơn 14 triệu log entry mỗi ngày. Các trường hợp sử dụng bảo mật và tuân thủ thường xuyên yêu cầu quét các file này sau sự kiện — xác định mọi yêu cầu được thực hiện bởi tài khoản dịch vụ bị xâm phạm (phân tích sau sự cố), xác nhận rằng token của một người dùng cụ thể đã hết hạn trước cửa sổ truy cập dữ liệu (kiểm toán tuân thủ), hoặc trích xuất toàn bộ tập hợp subject đã truy cập endpoint nhạy cảm trong cửa sổ bảo trì. Vì một file log có thể vượt quá vài gigabyte, tải nó vào bộ nhớ bằng readFileSync không khả thi. Node.js readline stream xử lý file từng dòng một với bộ nhớ cố định, giúp quét log tùy ý lớn trên laptop lập trình viên tiêu chuẩn.

Bạn sẽ không gặp vấn đề "file quá lớn cho bộ nhớ" với JWT riêng lẻ, vì một token đơn hiếm khi lớn hơn vài kilobyte. Tình huống thực sự xuất hiện là quét access log lớn hoặc audit trail cho JWT token, giải mã từng token, và trích xuất các claims cụ thể. Node.js stream xử lý điều này mà không cần tải toàn bộ file.

Node.js — stream log NDJSON và giải mã JWT nhúng
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 {
      // Bỏ qua các dòng bị lỗi định dạng
    }
  }

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

scanLogsForExpiredTokens("./logs/api-access-2026-03.ndjson");
Node.js — trích xuất JWT subject duy nhất từ log stream
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 {
        // Không phải JWT hợp lệ
      }
    }
  }

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

extractUniqueSubjects("./logs/gateway-2026-03.log");
Lưu ý:Chuyển sang streaming khi file log vượt quá 50 MB. Tải file NDJSON 500 MB bằng readFileSync sẽ chiếm bộ nhớ và gây GC pause. Phương pháp readline xử lý từng dòng một với mức sử dụng bộ nhớ cố định.

Các lỗi phổ biến

Dùng atob() mà không có TextDecoder cho các claims không phải ASCII

Vấn đề: atob() trả về chuỗi Latin-1. Các ký tự UTF-8 nhiều byte (emoji, CJK, ký tự có dấu) bị chia nhỏ qua các ký tự và hiển thị sai.

Cách sửa: Chuyển đầu ra của atob() thành Uint8Array, rồi đưa qua new TextDecoder('utf-8').

Before · JavaScript
After · JavaScript
// Bị lỗi với các JWT payload claims không phải ASCII
const payload = JSON.parse(atob(token.split(".")[1]));
// display_name hiển thị là "ç°ä¸­å¤ªé\x83\x8E" thay vì "田中太郎"
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 hiển thị đúng "田中太郎"
Quên thay thế ký tự base64url sang base64

Vấn đề: atob() ném ra "InvalidCharacterError" vì base64url dùng - và _ thay vì + và /.

Cách sửa: Thay - bằng + và _ bằng / trước khi gọi atob(). Node.js Buffer.from() với 'base64url' xử lý điều này tự động.

Before · JavaScript
After · JavaScript
// Ném ra: 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); // bây giờ hoạt động
Tin tưởng các JWT claims đã giải mã mà không xác minh chữ ký

Vấn đề: Bất kỳ ai cũng có thể tạo JWT với payload bất kỳ. Giải mã chỉ đọc dữ liệu — nó không chứng minh token được phát hành bởi auth server của bạn.

Cách sửa: Ở phía server, luôn xác minh chữ ký bằng jose.jwtVerify() hoặc jsonwebtoken.verify(). Chỉ giải mã là chấp nhận được cho hiển thị claims phía client.

Before · JavaScript
After · JavaScript
// NGUY HIỂM: đã giải mã nhưng chưa xác minh
const claims = JSON.parse(atob(token.split(".")[1]));
if (claims.role === "admin") {
  grantAdminAccess(); // kẻ tấn công có thể giả mạo điều này
}
import * as jose from "jose";
const { payload } = await jose.jwtVerify(token, secretKey);
if (payload.role === "admin") {
  grantAdminAccess(); // an toàn — chữ ký đã được xác minh
}
So sánh exp với Date.now() mà không chia cho 1000

Vấn đề: JWT exp tính bằng giây kể từ epoch, nhưng Date.now() trả về milliseconds. Phép so sánh sẽ luôn nói token hợp lệ vì timestamp millisecond lớn hơn 1000 lần.

Cách sửa: Chia Date.now() cho 1000 và làm tròn xuống trước khi so sánh với exp.

Before · JavaScript
After · JavaScript
// Lỗi: Date.now() là milliseconds, exp là seconds
if (payload.exp > Date.now()) {
  console.log("Token is valid"); // luôn đúng — sai!
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp > nowSeconds) {
  console.log("Token is valid"); // so sánh đúng
}

Phương thức giải mã JWT — So sánh nhanh

Phương thức
Môi trường
An toàn UTF-8
Xác minh chữ ký
Kiểu tùy chỉnh
Cần cài đặt
atob() + TextDecoder
Trình duyệt
N/A (chỉ đọc)
Không
Buffer.from()
Node.js
N/A (chỉ đọc)
Không
decodeURIComponent()
Trình duyệt (legacy)
N/A (chỉ đọc)
Không
jose
Cả hai
✓ (JWS/JWE)
npm install
jsonwebtoken
Node.js
npm install
jwt-decode
Cả hai
N/A
npm install

Dùng atob() + TextDecoder để giải mã phía trình duyệt khi bạn chỉ cần hiển thị claims cho người dùng. Dùng Buffer.from() trong script Node.js và CLI tool. Dùng jose ngay khi bạn cần xác minh chữ ký, tức là bất kỳ middleware xác thực phía server nào. Package jwt-decode là lựa chọn nhẹ nếu bạn muốn API một hàm để chỉ giải mã trong trình duyệt. Để kiểm tra trực quan nhanh mà không cần viết code, hãy dán token vào công cụ JWT Decoder.

Câu hỏi thường gặp

Làm thế nào để giải mã JWT token trong JavaScript mà không cần thư viện?

Tách token theo dấu ".", lấy phân đoạn thứ hai (payload), chuẩn hóa mã hóa base64url bằng cách thay - bằng + và _ bằng /, thêm ký tự = để đệm, sau đó gọi atob() rồi dùng TextDecoder để lấy chuỗi JSON UTF-8. Đưa kết quả qua JSON.parse() và bạn có được đối tượng claims. Không cần npm package. Phương pháp này hoạt động trên tất cả các trình duyệt hiện đại và Node.js 18+. Nếu bạn cũng cần đọc header, áp dụng các bước giải mã tương tự cho phân đoạn đầu tiên. Lưu ý rằng cách này cho bạn dữ liệu thô mà không có xác minh chữ ký — coi kết quả chỉ để hiển thị trừ khi bạn xác minh chữ ký phía server.

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" }

Sự khác biệt giữa atob() và Buffer.from() khi giải mã JWT là gì?

atob() là API trình duyệt giải mã Base64 chuẩn thành chuỗi nhị phân Latin-1. Nó không hiểu trực tiếp mã hóa base64url, vì vậy bạn phải thay thế các ký tự - và _ trước. Buffer.from(segment, "base64url") là API Node.js xử lý bảng chữ cái base64url một cách tự nhiên và trả về Buffer mà bạn có thể gọi .toString("utf-8"). Dùng atob() trong trình duyệt, Buffer.from() trong Node.js. Lựa chọn thứ ba — chậm hơn nhưng phổ biến trong lịch sử — là thủ thuật mã hóa phần trăm decodeURIComponent, nhưng mẫu này phụ thuộc vào hàm escape() đã bị deprecated trong một số đoạn code cũ và nên tránh trong code mới. Đối với code isomorphic chạy trên cả hai môi trường, hãy kiểm tra typeof Buffer !== "undefined" và phân nhánh tương ứng.

JavaScript
// Trình duyệt
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));

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

Tại sao atob() bị lỗi với các JWT claims không phải ASCII?

atob() trả về chuỗi Latin-1 trong đó mỗi ký tự ánh xạ tới một byte. Các chuỗi UTF-8 nhiều byte (emoji, ký tự CJK, chữ có dấu ngoài Latin-1) bị chia thành nhiều ký tự, tạo ra kết quả sai lệch. Cách sửa là chuyển chuỗi nhị phân thành Uint8Array trước, rồi truyền mảng đó vào new TextDecoder("utf-8").decode(). API TextDecoder tái tạo các chuỗi nhiều byte một cách chính xác. Vấn đề này dễ bị bỏ qua trong quá trình phát triển vì hầu hết các JWT payload chỉ chứa user ID, timestamps và tên role bằng ASCII — lỗi chỉ xuất hiện khi một claim chứa tên hiển thị không phải ASCII hoặc chuỗi đã được bản địa hóa. Luôn sử dụng đường dẫn TextDecoder trong code mới ngay cả khi payload hiện tại của bạn chỉ có ASCII, vì claims có thể thay đổi khi ứng dụng phát triển.

JavaScript
// Sai: atob trả về Latin-1, ký tự nhiều byte bị sai
const broken = atob(base64); // "ð\x9F\x8E\x89" thay vì emoji

// Đúng: chuyển thành mảng byte, rồi dùng TextDecoder
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const fixed = new TextDecoder("utf-8").decode(bytes);

Tôi có thể xác minh chữ ký JWT trong JavaScript không?

Giải mã và xác minh là hai thao tác khác nhau. Giải mã chỉ đọc payload, không được mã hóa. Xác minh kiểm tra chữ ký với một bí mật (HMAC) hoặc khóa công khai (RSA/ECDSA). Thư viện jose hỗ trợ cả hai trong trình duyệt qua Web Crypto API và trong Node.js. Package jsonwebtoken chỉ hoạt động trong Node.js. Không bao giờ tin tưởng claims đã giải mã mà không xác minh chữ ký ở phía server. Ở phía client, việc giải mã JWT để đọc tên hiển thị hoặc thời gian hết hạn của người dùng là chấp nhận được, nhưng bất kỳ quyết định kiểm soát truy cập nào — kiểm tra xem người dùng có vai trò hoặc quyền cụ thể không — phải xảy ra trong code phía server sau khi xác minh. Kẻ tấn công hiểu định dạng JWT có thể tạo token với claims tùy ý và kiểm tra phía client của bạn sẽ vượt qua.

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

Làm thế nào để kiểm tra JWT đã hết hạn trong JavaScript?

Giải mã payload và đọc claim exp, là Unix timestamp tính bằng giây. So sánh với thời gian hiện tại bằng Math.floor(Date.now() / 1000). Nếu thời gian hiện tại lớn hơn exp, token đã hết hạn. Nhớ rằng: giá trị exp tính bằng giây kể từ epoch, không phải milliseconds, vì vậy cần chia Date.now() cho 1000. Trong thực tế, hãy tích hợp một bộ đệm lệch đồng hồ nhỏ — kiểm tra xem token có hết hạn trong 30 giây tới không thay vì chỉ kiểm tra thời điểm hiện tại để tránh các trường hợp edge khi token còn hợp lệ khi bạn giải mã nhưng hết hạn khi lệnh gọi API downstream tiếp theo xử lý nó. Cũng xử lý trường hợp exp vắng mặt hoàn toàn, có nghĩa là token không bao giờ hết hạn.

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

Làm thế nào để viết code giải mã JWT isomorphic hoạt động cả trong Node.js và trình duyệt?

Kiểm tra sự tồn tại của globalThis.Buffer. Nếu nó tồn tại, bạn đang ở Node.js và có thể dùng Buffer.from(segment, "base64url").toString("utf-8"). Nếu không, bạn đang ở trình duyệt và nên dùng atob() với phương pháp TextDecoder. Bọc kiểm tra này trong một hàm decodeBase64Url duy nhất và dùng nó ở khắp nơi. Điều này quan trọng nhất đối với các utility package, design system component, và bất kỳ code chia sẻ nào trong monorepo package được import bởi cả Next.js server component và React component trên trình duyệt. Giữ phát hiện môi trường ở một nơi có nghĩa là bạn chỉ cần cập nhật ở một chỗ nếu runtime thay đổi — ví dụ khi Deno thêm hỗ trợ Buffer đầy đủ hoặc edge runtime mới yêu cầu code path khác.

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);
}

Công cụ liên quan

Cũng có sẵn trong: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 LaurentNgười đánh giá kỹ thuật

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.