JWT 指南

我如何在 JavaScript 解碼 JWT,而不把解碼誤認成驗證

我在瀏覽器與 Node.js 解碼 JWT 時,會先清理 Bearer 前綴、正確處理 Base64URL 與 UTF-8,再檢查 exp 等 claims,同時把 decode、signature verification 與 application validation 分開。

作者:JWTDecoder.tools最後確認 2026-09-13

編輯語氣說明:第一人稱敘述代表 JWTDecoder.tools 已文件化的驗證工作流程;發布者與組織作者皆為 JWTDecoder.tools。

JWT → Base64URL → UTF-8 → JSON
JWT → Base64URL → UTF-8 → JSON · decode ≠ verify

我在 JavaScript 解碼 JWT 時,第一個原則不是「怎麼把 payload 印出來」,而是先把三個狀態分清楚:decoded、cryptographically verified、accepted by my application。只要 header 與 payload 能被解析,最多只能證明我讀到了 token 自己聲稱的內容,不能證明簽章正確,也不能證明 API 應該接受它。

如果只是快速檢查,我會直接使用 JWT Decoder。需要在程式裡重現相同行為時,我會按照固定順序處理。

我解碼 JWT 的順序

  1. 移除可能存在的 Bearer 或 Authorization: Bearer 前綴。
  2. 先確認 compact token 的 segment 數量。
  3. 把 Base64URL 轉回 bytes。
  4. 以 UTF-8 解碼 bytes。
  5. 明確處理 JSON parsing error。
  6. 檢查 alg、kid、iss、aud、exp,但不把它們當成已驗證資料。

這個順序可以把「輸入格式錯誤」「Base64URL 錯誤」「UTF-8 錯誤」「JSON 錯誤」分開,而不是全部變成一個模糊的 parse failure。

先把輸入正規化

實際除錯時我常拿到的是:

Bearer eyJ...

甚至:

Authorization: Bearer eyJ...

我會先整理成純 compact token:

function normalizeJwtInput(input) {
  return input
    .trim()
    .replace(/^authorization\s*:\s*bearer\s+/i, "")
    .replace(/^bearer\s+/i, "")
    .trim()
    .replace(/^['"]|['"]$/g, "");
}

這一步只做格式清理,不推測 token 是否可信。

先檢查 segment 數量

一般的 signed JWT/JWS compact form 是:

header.payload.signature

通常有三段。compact JWE 則通常有五段。我不會直接寫:

JSON.parse(atob(token.split(".")[1]))

而是先確認結構:

function splitJwt(input) {
  const token = normalizeJwtInput(input);
  const parts = token.split(".");

  if (parts.length === 5) {
    throw new Error("看起來是 encrypted JWE,不是 3-part signed JWT");
  }

  if (parts.length !== 3) {
    throw new Error(`預期 3 個 JWT segments,實際為 ${parts.length}`);
  }

  return parts;
}

Base64URL 不是普通 Base64

JWT segment 使用 URL-safe alphabet,- 與 _ 取代 + 與 /,padding = 也常被省略。因此我會先轉換 alphabet 並補 padding。

function base64UrlToBytes(segment) {
  const base64 = segment
    .replace(/-/g, "+")
    .replace(/_/g, "/");

  const padded = base64.padEnd(
    base64.length + ((4 - (base64.length % 4)) % 4),
    "="
  );

  const binary = atob(padded);
  return Uint8Array.from(binary, char => char.charCodeAt(0));
}

atob() 只把編碼內容轉成 byte-like binary string,它本身沒有解決 UTF-8。

我明確用 UTF-8 解 JSON

JWT JSON 可以包含中文、日文、人名或其他非 ASCII 內容。如果只在純 ASCII 測試資料上驗證 one-liner,很容易誤以為程式永遠沒問題。

function decodeJsonSegment(segment, label) {
  let text;
  try {
    const bytes = base64UrlToBytes(segment);
    text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
  } catch {
    throw new Error(`${label} 不是有效的 Base64URL/UTF-8 資料`);
  }

  let value;
  try {
    value = JSON.parse(text);
  } catch {
    throw new Error(`${label} 不是有效 JSON`);
  }

  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`${label} 必須解碼成 JSON object`);
  }

  return value;
}

組成最小 decoder

function decodeJwt(input) {
  const [headerSegment, payloadSegment, signature] = splitJwt(input);

  return {
    header: decodeJsonSegment(headerSegment, "JWT header"),
    payload: decodeJsonSegment(payloadSegment, "JWT payload"),
    signature,
  };
}

這時我可以讀:

const decoded = decodeJwt(token);
console.log(decoded.header.alg);
console.log(decoded.header.kid);
console.log(decoded.payload.iss);
console.log(decoded.payload.aud);
console.log(decoded.payload.exp);

但我會在 UI 或 log 明確標示 decoded only。

exp 最大的坑是秒與毫秒

JWT NumericDate 使用 Unix epoch 秒。JavaScript Date.now() 是毫秒。

所以:

const expired = Date.now() >= payload.exp * 1000;
const expiresAt = new Date(payload.exp * 1000);

我不會寫:

Date.now() >= payload.exp

如果主要問題是 token 時間,我會用 JWT Expiry Checker 同時檢查 iat、nbf、exp 與有效區間。

在 Node.js 我通常使用 Buffer

function decodeBase64UrlJson(segment) {
  return JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
}

function decodeJwtNode(token) {
  const parts = token.trim().split(".");
  if (parts.length !== 3) throw new Error("需要 3-part signed JWT");

  return {
    header: decodeBase64UrlJson(parts[0]),
    payload: decodeBase64UrlJson(parts[1]),
    signature: parts[2],
  };
}

這仍然只是 inspection。伺服器真正要做 authentication 時,我會使用成熟的 JOSE/JWT library,並指定 allowed algorithm、issuer、audience 與 time policy。

使用 jwt-decode 時我仍保留同樣邊界

import { jwtDecode } from "jwt-decode";

const payload = jwtDecode(token);
const header = jwtDecode(token, { header: true });

jwt-decode 的用途就是 decode,不會因為 package 成功回傳 object,就自動變成 signature verification。

為什麼我避免 one-liner

JSON.parse(atob(token.split(".")[1]));

我不把它當可重用實作,因為它:

  • 沒確認 segment 是否存在;
  • 沒明確處理 Base64URL;
  • 沒處理 padding;
  • 可能錯誤處理 UTF-8;
  • 錯誤訊息很差;
  • 忽略 header 的 alg、kid;
  • 最容易讓人忘記 signature 根本還沒驗證。

Decode 後我下一步做什麼

如果我要知道 token 是否由可信任 key 簽發,我會使用 JWT Signature Verifier 或 JWKS Validator。

我把流程分成:

decode
→ 讀取 header/payload

verify signature
→ trusted key 是否真的簽了它

validate claims
→ iss/aud/exp/nbf 是否符合政策

authorize
→ scope/role 是否允許目前操作

JWT Validator 與 JWT Debugger 可以補上結構、時間與 401 排查。

我在瀏覽器處理 JWT 的原則

即使 payload 可讀,access token 仍然是 credential。我會:

  • 不把 JWT 放進 URL;
  • 不把完整 production bearer token 寫進 console/log;
  • 不為了除錯把 token 存進 localStorage/sessionStorage;
  • 優先使用 synthetic 或 redacted token;
  • 不為了單純 Base64URL decode 把 token 傳到伺服器。

JWTDecoder.tools 的 decoder 也是以瀏覽器記憶體內處理為原則。

我的 JavaScript JWT 解碼清單

  1. 清理 Bearer 前綴。
  2. 檢查 compact segment 數量。
  3. 正確轉換 Base64URL。
  4. 用 UTF-8 解 bytes。
  5. 分別處理 JSON error。
  6. 查看 alg、kid、iss、aud 與時間 claims。
  7. JavaScript 處理 NumericDate 時乘以 1000。
  8. 把結果稱為 decoded,而不是 verified。
  9. 需要 trust 時使用已知 key/JWKS 做 signature verification。
  10. 需要 acceptance 時再檢查 issuer、audience、expiration、token type 與 permissions。

對我來說,JavaScript 解碼 JWT 最重要的不是把程式壓縮成一行,而是讓 readable、verified、acceptable 三個狀態永遠不會被混在一起。

來源與參考資料

相關指南