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 編碼字串以 . 分隔:
- Header:宣告演算法
- Payload:claims(user id、過期時間等)
- 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:
- 短 expire + refresh token:access token 5 分鐘,撤銷只需把 refresh 失效
- denylist:把
jti或 user id 加入 Redis 黑名單,每次驗 token 多查一次
工程上多採用方案 1,方案 2 在登出 / 改密碼時用。
4.4 cookie vs Authorization header
| 屬性 | 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。
實際使用流程:
- 從 API 攔截需要 debug 的 JWT
- 開 supertools.tw/dev/jwt 貼到左側
- 右側看 decode 結果
- 確認
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 白名單,攻擊者可:
- 取得 server 公開的 RS256 公鑰
- 用此公鑰當 HMAC secret
- 用 HS256 簽 token
- server
verify(token, public_key)默認接受任何 alg,用公鑰驗 HMAC 通過 - 攻擊者偽造任意 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:
- 每次用 refresh 換 access 時,同時發新的 refresh 並把舊的標記失效
- 若收到已失效的 refresh → 強制整個 user 所有 session 登出 + 通知 user
- 即使攻擊者偷到 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。
修法:
- JWT payload 只放
user_id+ 主要 role - 細粒度權限放 Redis cache(user_id → permissions)
- 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 只放識別資訊。資安事件多半是這三條沒做好。
延伸閱讀: