JWT 完整教學:JSON Web Token 結構、安全、解碼工具實戰

寫給:建構 REST API / OAuth 流程、卻不知道 JWT 三段是什麼意思的後端工程師,以及看 eyJhbGciOiJI... 字串就頭痛的前端 / 資安人員。

JWT(JSON Web Token,發音 "jot")是 RFC 7519 定義的 token 規範,2010 年代被 Auth0 推廣後成為 Web 認證主流。它把 user identity + claims 編碼成單一字串,可以放 cookie、Authorization header、URL,server 拿到後一次驗證簽章就能信任內容。但 JWT 也是資安事故重災區——alg: none、key confusion、無 expire 三大坑每年都有人踩。本文 14 分鐘把 JWT 規範、實作、踩坑、工具一次講透。

1. JWT 是什麼

JWT 是一種自帶資料的 token 格式。傳統 session token 只是個隨機字串,server 必須查 DB 才知道對應到誰;JWT 則把 user 資訊直接編碼進 token 本身,server 用密鑰驗證簽章後即可信任內容,無需查 DB。

JWT 字串長這樣:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiQWxpY2UiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

三段 base64url 編碼字串以 . 分隔:

  1. Header:宣告演算法
  2. Payload:claims(user id、過期時間等)
  3. Signature:對前兩段的 HMAC 或非對稱簽章

decode 第 1、2 段就能看到原文(這也是為什麼永遠不要把密碼放 payload)。

優點:

  • 無狀態(stateless),水平擴展容易
  • 跨服務攜帶(微服務間單一 token)
  • 標準化(RFC 7519、Auth0 / Okta / Firebase 都支援)

缺點:

  • 一旦發出無法撤銷(必須等過期)
  • payload 變大會吃 header 配額(HTTP 8KB 上限)
  • 演算法選錯會被破解

2. 5 個核心觀念

2.1 三段結構解析

import json, base64

def b64url_decode(s):
    s += "=" * (-len(s) % 4)
    return base64.urlsafe_b64decode(s)

token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiQWxpY2UiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
h, p, s = token.split(".")

print(json.loads(b64url_decode(h)))
# {'alg': 'HS256', 'typ': 'JWT'}

print(json.loads(b64url_decode(p)))
# {'sub': '123456', 'name': 'Alice', 'iat': 1516239022}

print(s)
# SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

簽章由 HMAC_SHA256(base64url(header) + "." + base64url(payload), secret) 算出,server 用同樣 secret 重算後比較。

2.2 標準 claims

RFC 7519 定義 7 個保留 claim:

Claim 全名 用途
iss issuer 發行者
sub subject 主體(通常是 user id)
aud audience 目標受眾(service 名)
exp expiration 過期時間(Unix timestamp)
nbf not before 生效時間
iat issued at 發行時間
jti JWT ID 唯一 ID(用於黑名單)

自訂 claim 建議加 namespace 避免衝突:

{
  "sub": "user_12345",
  "exp": 1717564800,
  "https://example.com/role": "admin",
  "https://example.com/plan": "pro"
}

2.3 演算法選擇

Algo 類型 Key 結構 用途
HS256 HMAC + SHA-256 對稱(共用 secret) 單 server 內部
HS384, HS512 同上更長 hash 同上 同上
RS256 RSA + SHA-256 非對稱(私鑰簽 / 公鑰驗) 跨服務
ES256 ECDSA P-256 + SHA-256 非對稱 跨服務、token 較短
EdDSA Ed25519 非對稱 現代推薦
none 無簽章 無 絕對禁用

選擇原則:

  • 單 server / monolith:HS256(簡單快)
  • 多服務、需公開驗證:RS256 / ES256
  • 2026 年新專案:EdDSA(速度 + 安全均優)

2.4 簽章 vs 加密(JWS vs JWE)

JWT 預設只簽章不加密(JWS),意味著 payload 任何人都能 decode 看到原文。

# 解碼 payload 不需要 secret
import base64, json
payload_b64 = token.split(".")[1]
print(json.loads(b64url_decode(payload_b64)))

如果 payload 含敏感資訊(信用卡、身分證、地址),請改用 JWE(JSON Web Encryption):

from jwcrypto import jwt, jwk
key = jwk.JWK.generate(kty="oct", size=256)
claims = {"ssn": "123-45-6789"}
token = jwt.JWT(header={"alg": "A256KW", "enc": "A256CBC-HS512"}, claims=claims)
token.make_encrypted_token(key)

但更實務的做法:只放非敏感的識別資訊,敏感資料留在 DB 用 user id 查。

2.5 Access Token vs Refresh Token

短壽命 access token + 長壽命 refresh token 是業界標準:

Login → 拿到 access (15min) + refresh (30day)
   ↓
   每次 API call 帶 access
   ↓
Access 過期 → 用 refresh 換新 access
   ↓
Refresh 也過期 → 重新 login
屬性 Access Token Refresh Token
壽命 5-30 分鐘 7-90 天
攜帶位置 Authorization header HttpOnly cookie 或 DB
內容 user id + 權限 不透明字串(最好)
失效機制 等過期 DB 黑名單可即時撤銷

3. 實戰範例

範例 1:Node.js 簽發 HS256 JWT

const jwt = require("jsonwebtoken");

const secret = process.env.JWT_SECRET; // 至少 32 byte 隨機字串
const token = jwt.sign(
  { sub: "user_123", role: "admin" },
  secret,
  { algorithm: "HS256", expiresIn: "15m" }
);
console.log(token);

// 驗證
try {
  const decoded = jwt.verify(token, secret);
  console.log(decoded);
} catch (err) {
  console.error("Invalid token:", err.message);
}

範例 2:Python PyJWT 簽發 RS256

import jwt
from cryptography.hazmat.primitives import serialization

with open("private.pem", "rb") as f:
    private_key = serialization.load_pem_private_key(f.read(), password=None)

token = jwt.encode(
    {"sub": "user_123", "exp": 1717564800},
    private_key,
    algorithm="RS256"
)
print(token)

# 驗證(用公鑰)
with open("public.pem", "rb") as f:
    public_key = serialization.load_pem_public_key(f.read())

decoded = jwt.decode(token, public_key, algorithms=["RS256"])
print(decoded)

範例 3:Go 用 golang-jwt

package main

import (
    "fmt"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

func main() {
    claims := jwt.MapClaims{
        "sub": "user_123",
        "exp": time.Now().Add(15 * time.Minute).Unix(),
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    signed, _ := token.SignedString([]byte("my-secret-32-bytes-long-string"))
    fmt.Println(signed)
}

範例 4:純 bash 簽發(教學用)

#!/usr/bin/env bash
HEADER=$(echo -n '{"alg":"HS256","typ":"JWT"}' | openssl base64 -A | tr -d '=' | tr '/+' '_-')
PAYLOAD=$(echo -n '{"sub":"u1","exp":9999999999}' | openssl base64 -A | tr -d '=' | tr '/+' '_-')
SECRET="my-secret"
SIG=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64 -A | tr -d '=' | tr '/+' '_-')
echo "${HEADER}.${PAYLOAD}.${SIG}"

範例 5:前端從 localStorage 讀 JWT 並解出 user

function decodeJwt(token) {
  const payload = token.split(".")[1];
  const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
  return JSON.parse(decodeURIComponent(escape(json)));
}

const token = localStorage.getItem("access_token");
if (token) {
  const user = decodeJwt(token);
  console.log("User:", user.sub, "expires", new Date(user.exp * 1000));
}

注意:前端只 decode 不能 verify(沒有 secret),不要拿 decoded payload 做權限判斷,server 端永遠要 verify。

4. 進階技巧

4.1 JWKS(JSON Web Key Set)公鑰輪換

服務化系統用 JWKS endpoint 公開所有可用公鑰:

GET https://auth.example.com/.well-known/jwks.json

回傳:

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "key-id-2026-01",
      "use": "sig",
      "n": "...",
      "e": "AQAB"
    }
  ]
}

JWT header 帶 kid 指明用哪把 key 簽,驗證端從 JWKS 找對應公鑰。換 key 時加一把新的、舊的留一陣子讓存量 token 自然過期。

4.2 防 replay 攻擊用 jti

每個 JWT 給唯一 ID(UUIDv4),server 把已用過的 jti 存 Redis(TTL = token 剩餘壽命):

import uuid, jwt

token = jwt.encode({
    "sub": "u1",
    "jti": str(uuid.uuid4()),
    "exp": int(time.time()) + 900
}, secret)

# 驗證時
decoded = jwt.decode(token, secret, algorithms=["HS256"])
if redis.exists(f"jti:{decoded['jti']}"):
    raise ValueError("Token replayed")
redis.setex(f"jti:{decoded['jti']}", 900, "1")

4.3 即時撤銷(denylist)

JWT 預設無法撤銷,但有兩個 workaround:

  1. 短 expire + refresh token:access token 5 分鐘,撤銷只需把 refresh 失效
  2. denylist:把 jti 或 user id 加入 Redis 黑名單,每次驗 token 多查一次

工程上多採用方案 1,方案 2 在登出 / 改密碼時用。

屬性 Cookie Header
XSS 防護 HttpOnly cookie 可防 localStorage 易被 JS 偷
CSRF 防護 需 SameSite=Strict / CSRF token 不會自動帶上
跨域 需 CORS + credentials 需 CORS allow-headers

推薦:HttpOnly + SameSite=Lax cookie 放 access token,refresh token 同樣。前端不直接讀 token,全靠瀏覽器自動帶 cookie。

4.5 短 payload 控制

JWT 會放 HTTP header,瀏覽器多有 8KB 上限。常見技巧:

  • 不放完整 user object,只放 user id
  • 自訂 claim 用短 key(r 取代 role、p 取代 permissions)
  • 多 role 用 bitmask 編成數字

5. 常見錯誤與解法

錯誤 1:alg: none 漏洞

症狀:攻擊者把 header 改成 {"alg":"none"},server 不驗章直接信任。 根因:早期 JWT lib 預設接受 none。 解法:lib 設定 algorithms=["HS256"] 白名單,明確拒絕 none。

# 錯誤:未指定 algorithm
jwt.decode(token, secret)

# 正確
jwt.decode(token, secret, algorithms=["HS256"])

錯誤 2:key confusion(HS256 vs RS256)

症狀:server 用 RS256 公鑰當 HS256 secret。 根因:攻擊者用公鑰當 HMAC key 簽 token,server 用同一公鑰驗 HMAC 通過。 解法:lib verify 一定要鎖死 algorithm 白名單。

錯誤 3:exp 沒驗

症狀:永久有效 token,被偷後無法救。 根因:自己 split decode,忘了檢查 exp。 解法:永遠用 lib 的 verify,它會自動檢查 exp / nbf / iat。

錯誤 4:把密碼 / 信用卡放 payload

症狀:客戶端 decode 看到所有資料。 根因:誤以為 JWT 是加密。 解法:JWT 只簽章不加密。敏感資料放 DB,用 user id 查。

錯誤 5:secret 太短或寫死

症狀:secret 是 "secret" 或 "123456",被字典攻擊。 根因:開發圖方便。 解法:用 openssl rand -base64 32 產 256 bit 隨機字串,從環境變數讀。

openssl rand -base64 32
# 9k+xK3z1aP6h5L8tN0vBwQrYpEzM2sZ7uX9oA1cD3fI=

6. 用 SuperTools 線上版完成相同任務

不想開 Node REPL 就想看 JWT 內容?打開 SuperTools JWT 工具:

  • Decode:貼 JWT 即時看 header / payload JSON
  • Encode:填欄位生 token(HS256 簽章用瀏覽器 SubtleCrypto)
  • Verify:貼 secret / 公鑰一鍵驗章
  • Algorithm 偵測:自動標出 alg 並警告 none
  • Expiration 計算:顯示 exp 距現在多久(紅字提示已過期)

注意:工具完全 client-side,JWT 與 secret 不會上傳。但仍不建議把生產環境的 token 貼到任何線上工具(理論上瀏覽器 extension 可能側錄)。生產環境 debug 請用本地 lib。

實際使用流程:

  1. 從 API 攔截需要 debug 的 JWT
  2. 開 supertools.tw/dev/jwt 貼到左側
  3. 右側看 decode 結果
  4. 確認 exp 是否在合理範圍、alg 是否預期值

立即使用 SuperTools JWT 工具:https://supertools.tw/dev/jwt

7. 其他語言實作

Node.js(jsonwebtoken)

const jwt = require("jsonwebtoken");
jwt.sign({sub: "u1"}, "secret", {expiresIn: "1h"});
jwt.verify(token, "secret");

Python(PyJWT)

import jwt
jwt.encode({"sub": "u1"}, "secret", algorithm="HS256")
jwt.decode(token, "secret", algorithms=["HS256"])

Go(golang-jwt)

import "github.com/golang-jwt/jwt/v5"
t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "u1"})
s, _ := t.SignedString([]byte("secret"))

Rust(jsonwebtoken crate)

use jsonwebtoken::{encode, Header, EncodingKey};
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(b"secret"));

Bash(openssl)

見範例 4。

8. FAQ

Q1:JWT 跟 session cookie 差別? Session 由 server 維護狀態;JWT 由 client 攜帶狀態。前者好撤銷、後者好水平擴展。

Q2:要不要儲存 JWT 在 localStorage? 不要。XSS 攻擊可竊取。改用 HttpOnly cookie。

Q3:refresh token 一定要嗎? 強烈建議。短 access + 長 refresh 比單一長壽 token 安全得多。

Q4:要選 HS256 還是 RS256? 單服務內部 HS256;跨服務 / 需公開驗證 RS256 或 ES256。

Q5:JWT 可以撤銷嗎? 原生不行。需搭配 short-expire + denylist 模擬撤銷。

9. 實戰案例研究

案例 1:2018 Auth0 algorithm confusion 漏洞

2018 年研究員披露:部分 JWT lib(含早期 PyJWT、jsonwebtoken)若程式只設 secret 而沒指明 algorithms 白名單,攻擊者可:

  1. 取得 server 公開的 RS256 公鑰
  2. 用此公鑰當 HMAC secret
  3. 用 HS256 簽 token
  4. server verify(token, public_key) 默認接受任何 alg,用公鑰驗 HMAC 通過
  5. 攻擊者偽造任意 user

修法:

# 錯誤
jwt.decode(token, secret_or_public_key)

# 正確
jwt.decode(token, public_key, algorithms=["RS256"])  # 明確白名單

所有 JWT lib 2019 後預設 require algorithms 參數。但仍有遺留系統未升級。

教訓:JWT 永遠明確指定 algorithm 白名單。

案例 2:refresh token rotation 防 replay

某 fintech 把 access (15min) + refresh (30day) 都存 cookie。某天偵測到一個 user 同時有 5 個裝置同時操作(不可能)。

調查:refresh token 被偷後,攻擊者跟正主同時使用。

修法:refresh token rotation:

  1. 每次用 refresh 換 access 時,同時發新的 refresh 並把舊的標記失效
  2. 若收到已失效的 refresh → 強制整個 user 所有 session 登出 + 通知 user
  3. 即使攻擊者偷到 refresh,下次正主使用時系統會偵測到並中止

教訓:refresh token 必須 rotation + replay detection。

案例 3:JWT payload 過大導致 nginx 413

某內部 service 把 user 所有權限(200+ 個)放 JWT payload,token 變成 4.5KB。HTTP header 累積後超過 8KB,nginx 默認 large_client_header_buffers 拒絕,整批 API 503。

修法:

  1. JWT payload 只放 user_id + 主要 role
  2. 細粒度權限放 Redis cache(user_id → permissions)
  3. server middleware 第一步從 Redis 載權限

JWT 從 4.5KB → 400 byte,再無 header 超大問題。

教訓:JWT 不是萬能 container,只放識別資訊,狀態留 server side cache。

10. 結論 + 延伸閱讀

JWT 是現代 Web 認證的標準解法,但「能用」跟「用對」差很多。本文 5 個核心觀念 + 5 個踩坑覆蓋 90% 生產場景。下次設計 token 流程時,記得三件事:演算法白名單、refresh token rotation、payload 只放識別資訊。資安事件多半是這三條沒做好。

延伸閱讀: