再談 JWT 與 Session Cookie
# 軟體工程這篇文章聊聊我對 JWT 和 session 機制的看法,在這之前可以參考這篇文章:
JWT 和 Session Cookie 分別是什麼
Session Cookie
Session cookie 是最傳統的身份驗證機制。流程很單純:
- 使用者登入,伺服器產生一個隨機的 session ID
- Session ID 跟對應的使用者資料存在伺服器端(資料庫或快取)
- 伺服器透過
Set-Cookie把 session ID 回傳給瀏覽器 - 之後每次請求,瀏覽器自動帶上這個 cookie
- 伺服器收到請求後,用 Session ID 查出對應的使用者資料
Session ID 本身不包含任何資訊,它只是一把鑰匙。所有資料都存在伺服器端。
在瀏覽器的執行環境下,Cookie 具有自動帶入到每次請求的特性,在實務上需要實作 CSRF Token 來防止攻擊者偽造請求。伺服器如果只看「有沒有登入 cookie」,就可能誤以為這是你本人操作的。所以伺服器不能只信任 cookie,還要再檢查一個只有自己網站頁面才能拿到並一併送出的 token。
CSRF token 是伺服器發給前端的一個隨機驗證碼,用來防止別的網站冒用你已登入的身分偷偷送請求。攻擊者通常能騙你的瀏覽器發請求,但拿不到正確的 CSRF token,所以偽造請求會被擋下。
Cookie 的缺陷在於「無法判斷請求是否合法」,因此才需要在應用層另外實現。
JWT(JSON Web Token)
JWT 的思路完全相反:把使用者資訊直接編碼進 token 裡。
一個 JWT 由三個部分組成:Header(演算法資訊)、Payload(使用者資料)、Signature(簽名)。伺服器簽發 JWT 的時候,會用一個 secret key 對前兩部分做簽名。之後收到 JWT 時,只要驗證簽名就能確認資料沒被篡改,不需要查資料庫。
JWT 不是加密的。任何人都可以 decode 並讀取裡面的資料。簽名的作用是防止篡改,不是防止被讀取。所以不要在 JWT 裡放密碼或敏感資料。
目前主流的方案
在實務上,身份驗證的做法大致可以分成三種:
方案一:JWT + Client Storage
把 JWT 存在客戶端的儲存空間裡。可以是 sessionStorage、localStorage、IndexedDB,甚至是放在 JavaScript 的變數裡(in-memory)。每次發 API 請求時,JavaScript 從儲存空間取出 token,放進 Authorization header。
// 存 token
function setToken(token) {
sessionStorage.setItem('jwt', token)
}
// 發請求時帶上 token
async function fetchWithAuth(url, options = {}) {
const token = sessionStorage.getItem('jwt')
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
},
})
}
這個方案的特色是認證完全由 JavaScript 控制,不依賴瀏覽器的 cookie 機制。2011 年 Jesse Hallett 在 Cookies Are Bad for You 這篇文章中就提出了這個觀點:
The key is to choose a mechanism that is controlled by the web application, not the browser.
把認證從瀏覽器機制(cookie)移到應用層(JavaScript),好處是 CSRF 攻擊在這個架構下不成立——惡意網站無法透過 <form> 或 <img> 觸發帶有 Authorization header 的請求。
代價是失去 httpOnly 的保護。如果網站有 XSS 漏洞,攻擊者可以直接讀取 token。
方案二:JWT in Cookie + Refresh Token
把短時效的 JWT 放在 httpOnly=false 的 cookie(或 sessionStorage)裡,讓前端可以讀取 token 內容來決定 UI 狀態;同時把長效的 refresh token 放在 httpOnly=true 的 cookie 裡,降低洩漏風險。
Hasura 的 JWT Best Practices 推薦的就是這種架構,搭配 silent refresh 機制:
// JWT 過期前自動換新的
function isTokenExpired(token) {
const claims = JSON.parse(atob(token.split('.')[1]))
return claims.exp * 1000 < Date.now()
}
async function refreshToken() {
// refresh token 在 httpOnly cookie 裡,瀏覽器會自動帶上
const response = await fetch('/auth/refresh', {
method: 'POST',
})
const { jwt } = await response.json()
sessionStorage.setItem('jwt', jwt)
return jwt
}
async function fetchWithAuth(url, options = {}) {
let token = sessionStorage.getItem('jwt')
if (!token || isTokenExpired(token)) {
token = await refreshToken()
}
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
},
})
}
這個方案試圖在安全性跟靈活性之間找平衡:短時效 JWT 限制了洩漏的影響範圍,refresh token 的 httpOnly 保護了長期憑證。
在 SPA 為主的應用中,這個架構在使用者體驗上有明顯的優勢。因為 JWT 的 payload 裡帶有過期時間(exp),前端可以在 token 快要過期的時候主動去打 refresh token,整個過程在背景完成,使用者完全無感。不會出現「填了一整頁表單,按送出才發現 session 過期要重新登入」的情況。
// 設定一個 timer,在 JWT 過期前 1 分鐘自動 refresh
function scheduleTokenRefresh(token) {
const claims = JSON.parse(atob(token.split('.')[1]))
const expiresIn = claims.exp * 1000 - Date.now()
const refreshAt = expiresIn - 60 * 1000 // 過期前 1 分鐘
if (refreshAt > 0) {
setTimeout(async () => {
const newToken = await refreshToken()
scheduleTokenRefresh(newToken)
}, refreshAt)
}
}
Session cookie 要做到這件事需要額外設計。Cookie 的過期是瀏覽器控制的,前端無法直接得知 session 還剩多久。雖然可以透過 response header 或專用 API 回傳剩餘時間,但這不是 session cookie 內建的能力,需要自己實作。
方案三:Session Cookie
就是前面介紹的傳統做法。伺服器產生隨機 session ID,透過 Set-Cookie 回傳,瀏覽器自動處理。
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
沒有 token 管理、沒有 silent refresh、沒有簽名驗證。伺服器收到請求後查一次資料庫,找到對應的使用者資料,結束。
各方案比較
| JWT + Client Storage | JWT + Cookie + Refresh Token | Session Cookie | |
|---|---|---|---|
| XSS 風險 | Token 可被 JS 讀取 | 短效 JWT 可被讀取,refresh token 受 httpOnly 保護 | httpOnly 保護,JS 碰不到 |
| CSRF 風險 | 天生免疫(不走 cookie) | 需要 SameSite 配合 | 需要 SameSite 配合 |
| 撤銷能力 | 無法撤銷,等過期 | 可撤銷 refresh token | 隨時撤銷 |
| 伺服器狀態 | Stateless | Refresh token 需要存 DB | Session 存 DB 或快取 |
| 跨域支援 | 容易(Authorization header) | 部分容易 | 困難(第三方 cookie 限制) |
| 實作複雜度 | 中 | 高(silent refresh, token rotation) | 低 |
JWT 的隱藏成本
選擇 JWT 之後,有些成本必須要一起考量。
無法撤銷
JWT 一旦簽發,在過期之前都是有效的。如果你把 JWT 的有效期設成一天,那這一天內使用者就算登出,token 仍然有效。設成一週更危險——token 洩漏後攻擊者有整整一週的時間窗口。
Hasura 的文章對此的建議是:JWT 的有效期壓在 5–15 分鐘,搭配 refresh token 做 silent refresh。但如同他們自己也承認的:
Token deny-listing introduces central state again, and brings us back to what we had before using JWTs at all.
一旦你需要撤銷 token(不管是透過黑名單還是 refresh token),就又回到了 stateful 的世界,最終還是需要一個管理 token 的地方。
演算法選型
JWT 支援多種簽名演算法。如果簽發跟驗證都在同一台伺服器,用 HMAC(HS256)就夠了:速度快,金鑰短,安全強度足夠。
RSA 需要較長的金鑰,驗證的計算成本也高。除非你需要讓不同服務各自驗證(公鑰/私鑰分離的場景),否則 HMAC 是更合理的選擇。
另一個容易忽略的問題:如果你的 JWT library 沒有嚴格限制接受的演算法,攻擊者可能送出一個宣稱「不需要簽名」(alg: none)的 JWT,而伺服器就接受了。這不是理論上的風險,是真的發生過的漏洞。
Secret key rotation
簽名用的金鑰是一個巨大的單點故障。金鑰洩漏的後果是:攻擊者可以偽造任何 JWT,任何使用者、任何權限、任何過期時間。伺服器無法分辨,因為簽名完全合法。
你需要有金鑰輪替的機制。這代表你要同時支援多個金鑰版本——新簽發的用新金鑰,舊的 JWT 用舊金鑰驗證——而 JWT header 裡的 kid(Key ID)就是處理這件事用的。
這些都是 session cookie 不用煩惱的事情。Session ID 就是一個隨機字串,沒有簽名、沒有金鑰、沒有演算法可以被攻擊。
Refresh token 繞了一圈,還是要打資料庫
JWT 的一大賣點是 stateless:伺服器不用存任何東西,驗證簽名就好。
但前面提過,JWT 無法被撤銷。Refresh token 機制就是為了解決這個矛盾:把 JWT 的有效期壓得很短(幾分鐘),降低洩漏後的影響範圍;同時發一個長效的 refresh token,讓使用者不用頻繁重新登入。JWT 過期後,客戶端拿 refresh token 去換一個新的 JWT,使用者幾乎無感。
但 Refresh token 必須存在伺服器端(資料庫或快取),因為你需要能撤銷它。使用者登出時刪掉 refresh token;偵測到異常行為時讓所有 refresh token 失效。這些操作都需要查資料庫。
流程變成:
- 短時效 JWT(幾分鐘)用於一般請求——不查資料庫
- JWT 過期後,用 refresh token 換新的——查資料庫
- Refresh token 本身需要被管理——存在資料庫
如果同時在線使用者在幾萬以內,這跟直接用 session cookie 每次查資料庫的差距並不明顯。你省下的不用每次查 DB 的成本,被額外的 token 管理邏輯吃掉了。
現代架構下的 CSRF 和 XSS
CSRF:不再像以前那麼可怕
CSRF 曾經是 cookie 認證最大的痛點。2011 年 Jesse Hallett 寫 Cookies Are Bad for You 的時候,SameSite attribute 還不存在,CSRF token 的實作又容易出 bug(他在文中提到 stateful CSRF token 在 Ajax 應用裡是「a constant source of bugs」)。
當時把認證從 cookie 搬到 JavaScript 控制的 Authorization header 確實是合理的選擇。就算放到 2026 年,我認為在一些前提下,用 JWT 還是比 Session Cookie 單純多了。
前後端分離的架構下,CSRF 的風險比想像中低。大部分 API 請求的 Content-Type 是 application/json,這讓它成為非簡單請求(non-simple request),瀏覽器會先發 preflight 請求,Cross site 的惡意表單無法觸發這類請求。再搭配 SameSite=Lax,能擋掉絕大部分的 CSRF 攻擊向量。在現代前後端分離架構下,你甚至不一定需要實作傳統的 CSRF token 機制。
XSS:多層防禦
XSS(Cross-Site Scripting)是指攻擊者在你的網頁中注入惡意的 JavaScript,當其他使用者瀏覽該頁面時,這段腳本就會在他們的瀏覽器中執行。不管用哪個方案,XSS 都是需要面對的風險。差別在於 XSS 發生後的影響範圍。
httpOnly cookie 能防止攻擊者把 token 整個拿走,但擋不住攻擊者直接用受害者的瀏覽器發送請求或插入腳本。Huli 給我的一個啟發是:httpOnly 的作用是限制洩漏範圍,不是防禦 XSS 本身。
真正的防線是不要讓 XSS 發生:
- 現代前端框架(React、Vue、Svelte)預設會對輸出做 escape
- 嚴格的 CSP(Content Security Policy)限制
script-src的來源、禁止 inline script,能進一步壓縮 XSS 攻擊的成功率
如果 XSS 真的發生了,縮短 token 的有效期可以限制損害範圍。Access token 壓在 5–15 分鐘,refresh token 壓在 30–60 分鐘。攻擊者就算拿到 access token,可利用的時間窗口很短;而 refresh token 如果放在 httpOnly cookie 裡,攻擊者根本拿不到,無法在自己的環境重新產生 access token。
如果你認為 httpOnly=false 不可接受,那你也不應該接受把 token 放在 localStorage,因為兩者面對 XSS 的風險完全一樣。
什麼時候才需要 JWT?
JWT 真正發揮價值的場景是:
- 多個微服務需要獨立驗證身份:API Gateway 簽發 JWT,下游服務各自用公鑰驗證,不需要每個服務都去查認證資料庫
- 跨網域的單一登入(SSO):不同網域的服務需要共享認證狀態
- 大規模流量:同時在線使用者多到 session 查詢成為效能瓶頸
如果服務需要跨網域,JWT 確實比較方便。瀏覽器對第三方 cookie 的政策越來越嚴格——Chrome 預計逐步淘汰第三方 cookie,Safari 早已預設封鎖。在這個趨勢下,跨網域透過 cookie 傳遞身份變得越來越不可靠。JWT 透過 Authorization header 傳遞,完全不受這些限制。
會員系統不要自己做
不管你選 JWT 還是 session cookie,認證系統的實作細節都很容易出錯:密碼雜湊、token 過期處理、refresh token rotation、多裝置登出、brute force 防護。每一個環節都有安全風險,大部分的情況下不需要從頭實作。
現成的解決方案已經很成熟:
- Auth.js(前身是 NextAuth.js):支援多種框架,內建 OAuth 整合,session 管理開箱即用
- Supabase Auth:如果你已經用 Supabase,auth 直接整合,支援 OAuth 和 magic link
- Clerk、Auth0:完全託管的認證服務,適合不想自己維護認證基礎設施的團隊
這些工具幫你處理了 OAuth flow、session 管理、token rotation 等細節。你專注在產品邏輯就好。
選擇認證方案的核心問題只有一個:你的實際需求是什麼。單體架構、同網域、使用者量體不大,session cookie 就夠了。有跨網域、微服務、或大規模流量的需求,JWT 才值得它帶來的額外複雜度。簡單的方案不代表不專業,選擇的複雜度要跟實際需求匹配。