· 9 分鐘閱讀

再談 JWT 與 Session Cookie

# 軟體工程

這篇文章聊聊我對 JWT 和 session 機制的看法,在這之前可以參考這篇文章:

Session cookie 是最傳統的身份驗證機制。流程很單純:

  1. 使用者登入,伺服器產生一個隨機的 session ID
  2. Session ID 跟對應的使用者資料存在伺服器端(資料庫或快取)
  3. 伺服器透過 Set-Cookie 把 session ID 回傳給瀏覽器
  4. 之後每次請求,瀏覽器自動帶上這個 cookie
  5. 伺服器收到請求後,用 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 存在客戶端的儲存空間裡。可以是 sessionStoragelocalStorageIndexedDB,甚至是放在 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 放在 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 ID,透過 Set-Cookie 回傳,瀏覽器自動處理。

Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

沒有 token 管理、沒有 silent refresh、沒有簽名驗證。伺服器收到請求後查一次資料庫,找到對應的使用者資料,結束。

各方案比較

JWT + Client StorageJWT + Cookie + Refresh TokenSession Cookie
XSS 風險Token 可被 JS 讀取短效 JWT 可被讀取,refresh token 受 httpOnly 保護httpOnly 保護,JS 碰不到
CSRF 風險天生免疫(不走 cookie)需要 SameSite 配合需要 SameSite 配合
撤銷能力無法撤銷,等過期可撤銷 refresh token隨時撤銷
伺服器狀態StatelessRefresh token 需要存 DBSession 存 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 失效。這些操作都需要查資料庫。

流程變成:

  1. 短時效 JWT(幾分鐘)用於一般請求——不查資料庫
  2. JWT 過期後,用 refresh token 換新的——查資料庫
  3. 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-Typeapplication/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
  • ClerkAuth0:完全託管的認證服務,適合不想自己維護認證基礎設施的團隊

這些工具幫你處理了 OAuth flow、session 管理、token rotation 等細節。你專注在產品邏輯就好。


選擇認證方案的核心問題只有一個:你的實際需求是什麼。單體架構、同網域、使用者量體不大,session cookie 就夠了。有跨網域、微服務、或大規模流量的需求,JWT 才值得它帶來的額外複雜度。簡單的方案不代表不專業,選擇的複雜度要跟實際需求匹配。

相關文章

探索其他主題