JWT 常被誤解成「解得開就可信」的登入信任資料。實際上,Base64url 解碼只把字串還原成 JSON,不會證明發行者、接收對象、有效期間或內容完整性。安全的 JWT 驗證必須先固定可接受的演算法與金鑰,再驗證簽章,最後依應用情境檢查 iss、aud、exp、nbf 等 claims;任何必要條件不成立,都應拒絕而不是降級放行。
本文處理的是「收到 JWT 後,API 如何建立可測試的信任邊界」,不是登入畫面、OAuth 授權流程或權杖儲存選型。搜尋意圖屬於資訊型與實作診斷型:讀者應能區分解碼與驗證、辨識演算法混淆風險,並在本機沙箱重現竄改、錯誤 audience 與過期 token 的拒絕結果。
直接答案:JWT 解碼不等於驗證
直接答案:JWT 的 header 與 payload 可以在沒有金鑰時被讀取;可信度來自受限制的演算法、正確金鑰、簽章驗證與 claims 驗證。只呼叫 decode、只看三段格式,或只檢查 exp,都不足以建立身分信任。
RFC 7519 將 JWT 定義為緊湊、URL-safe 的 claims 表示法。Claims 可以放在 JWS 中受到數位簽章或訊息鑑別碼保護,也可以放在 JWE 中加密。這個區分很重要:常見的三段 signed JWT 提供完整性與來源驗證能力,不等於 payload 保密。任何取得 token 的人通常都能將前兩段做 Base64url 解碼,因此敏感認證資料、私人資料、內部機密或不應暴露的營運資訊都不該因為「有簽章」就放進 payload。
JWT 驗證至少包含四個彼此獨立的問題:格式是否可解析、cryptographic 保護是否有效、claims 是否符合規則、此 token 是否適用於目前應用。前兩項回答「內容是否被改過、是否由持有相對應金鑰的一方產生」;後兩項回答「即使簽章有效,這個 token 現在是否能用在這個 API」。把它們合併成單一 decode() 結果,會讓未驗證資料提早進入授權判斷。
處理順序也應明確。先限制整體大小與三段結構,再解析最小必要 header;將 alg 與伺服器端設定的白名單比對,而不是讓 token 自己決定驗證策略;驗證簽章成功後,才把 payload 當成候選 claims;最後檢查發行者、接收者與時間條件。解析錯誤、未知演算法、找不到金鑰、簽章不符或必要 claim 缺失都應回到相同的未授權邊界,詳細原因只留在不含 token 的伺服器診斷中。
三段結構代表什麼,不代表什麼
JWT 的三段常寫成 header.payload.signature,但「有三段」只表示外形符合某種緊湊序列化;它不保證演算法安全、簽章正確、claims 完整,也不保證 token 是為目前服務簽發。
Header 通常包含 alg,可能也有 typ 或金鑰識別資訊。Payload 是 claims 集合;RFC 7519 登錄了 iss、sub、aud、exp、nbf、iat 與 jti 等名稱,但註冊名稱不代表每個應用都會自動驗證。Signature 或 MAC 針對編碼後的 header 與 payload 計算,驗證端必須以預先建立的信任設定選擇金鑰與演算法。
Base64url 是編碼,不是加密。把 payload 從 ey... 還原成 JSON 不需要共享密鑰,也不會觸發簽章驗證。因此,除錯工具顯示 claims、前端程式成功解析 sub、或 middleware 能讀到 exp,都不能單獨當成通過證據。未驗證 claims 最多只能用於選擇候選處理路徑,不能直接授權、切換租戶、決定資料範圍或建立使用者身分。
簽章有效也不是萬用通行證。某個測試服務與正式 API 若共用驗證金鑰,但沒有檢查 aud,原本發給測試服務的 token 可能被錯誤接受。多個發行者使用不同命名規則時,若沒有鎖定 iss,同名 sub 也可能被映射到錯誤身分。時間 claim 則處理生命週期:exp 已到期要拒絕,nbf 尚未到達要拒絕,時鐘容忍值要小且由伺服器設定,不能由 token 提供。
演算法與金鑰選擇必須由驗證端控制
安全邊界的核心不是「支援越多演算法越好」,而是每一種 token 類型只接受明確演算法、明確金鑰來源與明確 claims 規則;token header 只能提供待比對資料,不能擴張伺服器原先允許的選項。
RFC 8725 要求實作讓呼叫端指定支援演算法集合,而且不得使用集合以外的演算法。這防止應用把攻擊者控制的 alg 當成指令。驗證端應在部署設定中固定允許清單,例如某一條內部 token 契約只接受 HS256,另一條公開金鑰契約只接受指定的不對稱演算法;兩者的金鑰型別、發行者與 audience 規則應分離,而不是放進「自動猜測」流程。
none 不應在預期有簽章的路徑被接受。對稱 MAC 與不對稱簽章也不能任意互換:MAC 驗證者持有的資料同時具備產生 MAC 的能力,非對稱簽章則讓驗證端只持有公開金鑰。選型取決於信任拓樸,但程式碼必須知道自己正在驗證哪一種契約。若一個函式同時接收任意演算法、任意 key material 與任意 token,審查者很難證明不存在混淆路徑。
金鑰識別欄位同樣只是索引提示。應用可以用受限制的識別值從預先信任的集合選取候選金鑰,卻不該把 header 中的 URL 當成任意遠端下載指令,也不該把檔案路徑、SQL 片段或未限制的外部輸入交給金鑰載入器。本文沙箱直接在程序記憶體產生隨機 HMAC key,不連線金鑰服務,也不輸出 token 或 key。
還要避免不同 JWT 種類彼此替代。存取 token、ID token、電子郵件確認 token 即使都使用 JWT,也可能有不同 audience、必要 claims 與生命週期。RFC 8725 建議使用明確 typing、互斥的驗證規則、不同 audience 或不同金鑰等方式防止 cross-JWT confusion。工程上的具體做法,是為每個用途建立不同 validator 設定與測試案例,而不是建立一個「只要簽章過就通用」的入口。
Claims 驗證如何成為授權前的契約
Claims 驗證應由伺服器端配置驅動,並把缺值、型別錯誤、比對失敗與時間失效視為拒絕。應用只有在完整驗證成功後,才能把 sub 或權限 claims 轉成授權上下文。
iss 用來識別發行者。驗證端應做精確比對,不要用模糊前綴、大小寫猜測或「只要是 HTTPS 就接受」。aud 表示預期接收者,可能是單一字串或陣列;函式庫若提供 audience 驗證選項,應傳入目前服務的固定值,並確認其陣列語意。不要因為 token 含有某個角色 claim,就跳過 issuer 與 audience。
exp 是 token 不得再被接受的時間,nbf 是在該時間之前不得接受,兩者都以 NumericDate 表示。驗證器需要可靠時鐘,且可設定小幅 clock skew 來吸收合理誤差,但不能以數小時的寬限掩蓋同步問題。若應用規格要求 exp,缺少時應失敗;不能把「RFC 將某 claim 定義為 optional」誤解成「應用永遠可以忽略」。應用契約可以比基礎格式更嚴格。
iat 描述簽發時間,可協助判斷 token 年齡,但不取代 exp。jti 可提供唯一識別值,是否用於重放控制取決於系統是否維護相對應狀態。JWT 並不自動提供撤銷:若業務需要立即登出、裝置撤銷或風險封鎖,必須另行設計短效 token、伺服器端狀態或其他撤銷機制。這是架構取捨,不能用「stateless」口號掩蓋。
驗證錯誤對外應保持一致,例如回傳一般未授權結果,避免透露金鑰識別、有效帳號或內部規則;對內則記錄錯誤類別、追蹤識別與 validator 版本,但不要記錄完整 bearer token。可觀測性重點是「哪一道規則拒絕」與「哪個程式版本執行」,不是把 bearer 字串複製到 log。
驗證與重現:Node.js 沙箱證明六個邊界
本機測試使用 Node.js 24.19.0 內建 node:crypto,以程序內隨機 key 建立一次性 HS256 fixture;它實際證明 payload 可讀、有效 token 通過,而竄改、錯誤 audience、過期與演算法替換都被拒絕。
執行情況:已於排程環境實際執行。 node jwt_validation_lab_20261006.js 回傳 exit code 0,摘要為 6/6 checks passed on Node.js v24.19.0。測試沒有連線網路服務、沒有使用真實帳號、沒有輸出 key,也沒有把產生的 token 寫入檔案。這是格式與驗證控制的沙箱證據,不代表特定框架或正式驗證服務已通過安全審查。
- 在文章測試檔所在目錄執行
node --version,確認版本為v24.19.0;若使用其他固定版本,需將實際版本連同結果記錄,不能直接沿用本文結論。 - 執行
node jwt_validation_lab_20261006.js。先建立含固定 issuer、audience、iat與exp的一次性 token;預期有效簽章與 claims 通過,且不驗證也能讀到 payload。 - 測試檔改寫 payload 的
sub但保留舊 signature;預期得到signature rejected。這證明重新編碼 JSON 不會讓竄改內容通過完整性檢查。 - 測試檔分別建立錯誤 audience 與已過期 token;預期得到
audience rejected與expired,證明簽章有效不代表適用於目前 API 或目前時間。 - 測試檔將 header 的演算法替換為
none;預期在任何簽章運算前得到algorithm rejected,證明演算法白名單由驗證器控制。
將以下完整沙箱程式存成 jwt_validation_lab_20261006.js,使用獨立目錄與預設 CommonJS 模式執行;不要放入設定 "type": "module" 的套件。此程式只示範六項檢查,不是正式驗證器。
'use strict';
const crypto = require('node:crypto');
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const decode = (value) => JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
const key = crypto.randomBytes(32);
const expectedIssuer = 'https://issuer.example.invalid';
const expectedAudience = 'sandbox-api';
const now = Math.floor(Date.now() / 1000);
function sign(payload) {
const header = { alg: 'HS256', typ: 'JWT' };
const signingInput = `${encode(header)}.${encode(payload)}`;
const signature = crypto.createHmac('sha256', key).update(signingInput).digest('base64url');
return `${signingInput}.${signature}`;
}
function verify(token) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('segment count');
const [headerPart, payloadPart, signaturePart] = parts;
const header = decode(headerPart);
if (header.alg !== 'HS256') throw new Error('algorithm rejected');
const expected = crypto.createHmac('sha256', key)
.update(`${headerPart}.${payloadPart}`)
.digest();
const supplied = Buffer.from(signaturePart, 'base64url');
if (expected.length !== supplied.length || !crypto.timingSafeEqual(expected, supplied)) {
throw new Error('signature rejected');
}
const payload = decode(payloadPart);
if (typeof payload.exp !== 'number' || payload.exp now) throw new Error('not active');
if (payload.iss !== expectedIssuer) throw new Error('issuer rejected');
if (payload.aud !== expectedAudience) throw new Error('audience rejected');
return payload;
}
function expectPass(name, operation) {
operation();
console.log(`PASS ${name}`);
}
function expectReject(name, operation, expectedMessage) {
try {
operation();
throw new Error(`test did not reject: ${name}`);
} catch (error) {
if (error.message === `test did not reject: ${name}`) throw error;
if (error.message !== expectedMessage) {
throw new Error(`${name}: expected "${expectedMessage}", got "${error.message}"`);
}
console.log(`PASS ${name}: ${error.message}`);
}
}
const validPayload = {
sub: 'sandbox-user',
iss: expectedIssuer,
aud: expectedAudience,
iat: now,
exp: now + 60,
};
const validToken = sign(validPayload);
expectPass('valid signature and claims', () => verify(validToken));
expectPass('payload is readable without verification', () => {
const payload = decode(validToken.split('.')[1]);
if (payload.sub !== 'sandbox-user') throw new Error('payload decode failed');
});
const validParts = validToken.split('.');
const tamperedToken = `${validParts[0]}.${encode({ ...validPayload, sub: 'changed-user' })}.${validParts[2]}`;
expectReject('tampered payload', () => verify(tamperedToken), 'signature rejected');
expectReject(
'wrong audience',
() => verify(sign({ ...validPayload, aud: 'different-api' })),
'audience rejected',
);
expectReject(
'expired token',
() => verify(sign({ ...validPayload, iat: now - 120, exp: now - 60 })),
'expired',
);
expectReject(
'algorithm substitution',
() => {
const alteredHeader = encode({ alg: 'none', typ: 'JWT' });
verify(`${alteredHeader}.${validParts[1]}.${validParts[2]}`);
},
'algorithm rejected',
);
console.log('SUMMARY 6/6 checks passed on Node.js ' + process.version);
預期輸出(已於本次環境重現):
PASS valid signature and claims
PASS payload is readable without verification
PASS tampered payload: signature rejected
PASS wrong audience: audience rejected
PASS expired token: expired
PASS algorithm substitution: algorithm rejected
SUMMARY 6/6 checks passed on Node.js v24.19.0
所有斷言符合預期時,測試程序以 exit code 0 結束,包括正確拒絕無效 token 的案例;若未拒絕或拒絕原因不符,程序才以非零碼結束。沙箱僅支援字串 audience,固定擷取啟動時間,且未完整驗證 nbf 型別、有限數值與輸入大小;不要作為正式 middleware。
這份沙箱程式用於展示驗證順序,不是完整 JWT 函式庫。正式系統應使用維護中的成熟實作、固定設定與框架整合測試,並確認 JSON 重複成員、Unicode、數值範圍、金鑰輪替及錯誤處理符合所選規格。自行撰寫 cryptographic 協定實作會擴大審查範圍;沙箱的價值是把必要拒絕條件做成可觀察測試。
實作檢查清單與常見失敗模式
把 JWT 驗證視為一組 fail-closed gates:輸入限制、演算法、金鑰、簽章、issuer、audience、時間與用途全部通過後才建立 principal;任何一步例外或缺值都不可退回未驗證 claims。
- 只 decode 就授權: 解碼適合除錯或讀取不可信資料,不是驗證。修正方式是讓授權 middleware 只接收 validator 成功回傳的 principal。
- 信任 token 的
alg: Header 是輸入,不是政策。修正方式是在伺服器設定中固定允許集合,並測試none與錯誤演算法被拒絕。 - 只驗 signature: 其他服務簽發、過期或用途不同的 token 仍可能有合法簽章。修正方式是強制
iss、aud、exp,並依用途增加必要 claims。 - 共用一組通用驗證設定: 不同 token 類型可能互相替代。修正方式是依 token 用途分離 validator、audience、typing 與測試。
- 把 token 寫進 log: Bearer token 被取得後可能在有效期內被重放。修正方式是只記錄匿名化追蹤資訊與拒絕類別。
- 把簽章當加密: Payload 仍可讀。修正方式是最小化 claims,敏感資料留在受控伺服器端;若需求確實是保密,評估符合規格的加密機制,而不是期待 JWS 隱藏內容。
- 沒有負向測試: 只測成功案例無法證明 fail-closed。至少加入竄改、過期、尚未生效、錯誤 issuer、錯誤 audience、未知演算法與 malformed token。
採用前應回答:誰是可信發行者、哪個服務是 audience、允許哪些演算法、金鑰如何輪替、最大 token 大小與有效期是多少、是否需要撤銷、錯誤如何記錄,以及每種 token 是否有互斥規則。這些答案要落入設定、測試與監控,而不是只存在架構圖。
結論:把「可解析」與「可信任」分開
JWT 的便利在於以緊湊格式傳遞 claims,不在於免除驗證狀態與安全設計。Header 與 payload 可讀是預期行為;真正的信任來自驗證端預先設定的演算法與金鑰、成功的 cryptographic 驗證,以及符合目前 API 的 claims 契約。
最小安全基線很清楚:不以 decode 結果授權、不接受白名單外演算法、不把簽章有效等同於用途正確、完整檢查 issuer、audience 與時間,並為所有拒絕路徑建立可重現測試。如此才能讓 JWT 從「看起來像可信資料的字串」變成具有明確邊界、可審查且 fail-closed 的驗證輸入。
官方依據:RFC 7519:JSON Web Token、RFC 8725:JWT Best Current Practices、Node.js Crypto API。