在前端 / 後端開發圈,「用 JWT 做登入驗證」幾乎已經是預設選項,很多教學、範例專案打開就是 jsonwebtoken 直接簽一包塞進 cookie 或 localStorage。但 Stop using JWTs! 這篇 Gist 提出了一個很直接的反對意見:
JWTs should not be used for keeping your user logged in. They are not designed for this purpose, they are not secure, and there is a much better tool which is designed for it: regular cookie sessions.
這篇文章記錄我讀完之後的整理,包含作者的核心論點、對常見反駁的回應,以及我自己在實務上觀察到的紀錄分享。
先講結論:JWT 不等於「安全的登入機制」
很多人把 JWT 跟「安全」畫上等號,但這其實是規格用途上的誤會。作者的主張很清楚:JWT 不是為「維持登入狀態」這種長生命週期的場景設計的,硬拿來用,反而會犧牲安全性跟彈性。
作者的三個核心論點
1. JWT 適合的場景是短生命週期
作者主張 JWT 適合的使用情境是極短的存活時間,實務上通常建議 5 分鐘或更短——這是把它當「一次性授權憑證」的用法。而一般使用者的登入 session 動輒數小時、數天甚至數週,已經超出這個原本適用的範圍。
(RFC 7519 本身並沒有規定 token 的存活時間,「5 分鐘」是作者跟主流資安實務的建議,不是規格明文規定。)
2.「無狀態」認證在安全實踐上不成立
這是整篇文章關鍵的一點。JWT 常被包裝成「無狀態(stateless)」的賣點——伺服器不用查資料庫,直接驗簽章就能信任 token。但作者引用 joepie91 的文章 指出:
- 真正安全的認證機制,仍然需要某種方式維持狀態(例如:token 被撤銷、使用者被登出、密碼被重設後舊 token 要失效)
- 既然最終還是得存一份狀態資料,那不如直接存完整的 session 資料,而不是先簽一個 JWT,之後還要另外處理撤銷、黑名單這些「無狀態」處理不了的問題
換句話說,「無狀態」在話術上很吸引人,但落地到真實系統時,往往只是把狀態管理的複雜度換了個地方藏起來,而不是真的消失。
規格本身在資安圈的評價也不高——Paragon Initiative 的文章 指出,JWT 規格甚至允許偽造 token(例如惡名昭彰的 alg: none 問題),這種先天缺陷讓人擔心還有其他沒被發現的坑。
3. 效率跟靈活性都不如傳統 session
如果 JWT 裡面存的東西本質上就是一組 session 資訊,那用 JWT 存反而比傳統 session cookie 更沒效率、更不靈活——因為每次都要把整包資料塞進 token 裡傳來傳去,而不是伺服器端存一份、client 只帶一個輕量的 session id。
常見反駁,作者怎麼回應
以下這些反駁幾乎就是我自己心裡會出現的疑問。
「Google 不是也在用 JWT 嗎?」
作者的回應是:Google 在瀏覽器裡維持使用者登入狀態,用的其實是傳統的簽章加密 cookie session(例如 SID、HSID 這類 cookie,記錄帳號 ID 與最近登入時間),JWT 只出現在單一登入(SSO)情境——把「某伺服器上已登入」這件事轉換成「另一個伺服器也承認你已登入」的傳輸媒介。這正好符合 JWT 原本設計的短生命週期、單次驗證用途,而不是拿來當作日常登入狀態的儲存機制。
「無狀態架構比較潮,效能比較好吧?」
作者的回應很直接:完全無狀態的認證在安全上做不到。可以參考另一篇他寫的 Stateless is a lie。追求「無狀態」這個目標本身,可能就已經走偏了方向。
「我不知道怎麼設定 session,JWT 比較簡單」
這其實是最常見的理由。作者的回應是:session 不是新技術,只是這幾年因為 JWT 太紅,相關教學變少、顯得比較「過時」而已。絕大多數後端框架(Rails、Django、Laravel 等)都內建或很容易整合 session 機制;Node.js 生態因為高度模組化,需要自己組裝,可以搭配 express-session 加上 store(例如 connect-session-knex 接 PostgreSQL / MySQL)。
流程上跟 JWT 最大的差異在於:伺服器端保有一份「這個 session id 目前是誰、有沒有效」的紀錄,撤銷、登出、強制過期都只是改一筆資料,不需要額外設計黑名單機制。
那真的需要短期 token 怎麼辦?
作者建議可以考慮 PASETO——一個從設計之初就把安全性放在第一位的規格,用來取代 JWT 在一次性授權、跨服務短暫驗證這類場景的角色。但作者也強調:PASETO 一樣不該拿來做 session 管理。這個界線也呼應 OWASP Vancouver 的簡報 提到的 JWT 合理用途:授權(authorization)而非會話管理、存活時間短、預期只使用一次(驗證完就去換取真正的 session)。
換個角度看:JWT 和 Session 未必是二選一
整理完 Gist 的論點之後,我又讀到另一篇角度更中立的文章 《JWT 跟 Session Cookie 登入認證的差異》(作者 chia1104),是從面試官視角出發,把兩種機制的實務做法拆得很細,剛好補上 Gist 那篇比較強硬立場沒講到的東西。
JWT 的「refresh」跟 Session 的「refresh」其實是兩件事
這是原文講得最清楚、也是我自己之前最容易搞混的地方:
- JWT 的 refresh:短命的
access_token搭配長命的refresh_token。access_token過期後,前端拿refresh_token去換一顆全新的access_token,整包內容換掉,兩者通常都存在localStorage。 - Session Cookie 的「refresh」:正式名稱是 sliding expiration,伺服器在每次合法請求時,把該筆 Session 在 store(例如 Redis)裡的過期時間往後延,瀏覽器手上的
sessionIdcookie 整個登入期間通常不會變。
同樣叫「刷新」,一個是發新證件,一個是幫舊證件延簽,機制完全不是同一個層次的東西。
兩者的實務比較
原文附了一張比較表,我把重點摘錄如下(表格整理自原文,完整細節與案例可見原文連結):
| 面向 | JWT(Bearer token) | Session Cookie |
|---|---|---|
| 狀態存放 | 無狀態,登入資訊在 token 本身,前端保存 | 有狀態,資訊存在後端 Session store,前端只存 sessionId |
| 擴充性 | 適合多服務、跨網域、微服務架構 | 單機或搭配 Redis 共享即可,適合一般規模系統 |
| 撤銷登出 | 純無狀態下難以即時撤銷,需要黑名單機制 | 伺服器刪 Session 即可即時生效 |
| 安全風險 | 為了塞進 Authorization header,通常放 localStorage,JS 讀得到,XSS 得手就能直接冒用 |
Cookie 只是隨機 ID,可搭配 HttpOnly/Secure/SameSite,JS 完全讀不到 |
| 適用情境 | SPA、行動 App、對外 API、微服務 | 傳統 Web、後台系統、單體架構 |
安全風險那一列是重點:JWT 存 localStorage 之所以危險,正是因為它「必須」被 JS 讀到才能塞進 header;而 session cookie 之所以能相對安全,是因為它可以走 HttpOnly,瀏覽器會自動帶、JS 完全碰不到。
跟 Gist 文章的差異
Gist 作者是資安背景,結論偏向「乾脆別在 session 場景用 JWT」;chia1104 這篇則更貼近多數團隊的實際狀況——他認為兩者很少是純二選一,常見做法反而是混搭:Web 前台用 Session Cookie 管登入狀態,服務與服務之間、或對外開放的 API,再用 JWT 做授權資訊傳遞。專案規模小、單一後端時,Session Cookie 通常更好維護;一旦拆成微服務或要對外開 API,JWT「自我描述、可跨服務驗證」的特性才真正派上用場。
這點我覺得比 Gist 的立場更貼近現實:兩篇合在一起讀反而更完整——一篇提醒你 JWT 的代價常被低估,一篇提醒你選型該看架構規模。
一個實際的案例:寫好的 refresh 機制,不代表真的有在跑
這是我自己在實務上觀察到、覺得值得記錄下來的一個狀況:
一個 Vue 3 + Pinia 的前端專案,登入機制是典型的 JWT 模式。拆開來看每個角色的分工,會比條列規格更容易懂:
- 後端:登入成功時,決定「這 token 到哪個時間點為止有效」,把這個 Unix 時間戳當一個獨立欄位(
expiration)連同token / type一起回傳;之後每次請求,只驗證 token 的簽章沒被竄改、再拿expiration跟伺服器自己當下的時間比對,完全不查任何「這個人現在是否登入中」的紀錄——這就是「無狀態」的具體樣子 - localStorage:單純的持久化儲存,本身不會主動變化,只有在登入成功、refresh 成功、登出這三個時機,才會被程式碼整包覆寫或清除
- Pinia store:把 localStorage 的內容鏡射一份到記憶體,讓畫面能用
isLogin這種 computed 直接判斷登入狀態;這份鏡像不會自動跟 localStorage 同步,而是同一個setToken()動作裡手動把兩邊各寫一次 - axios request interceptor:每次送出請求前,都重新從 localStorage 讀一次最新 token 塞進
Authorizationheader——不是快取住舊值,所以 localStorage 一被更新,下一個請求就自動吃到新 token - 過期判斷:前端自己拿
expiration這個時間戳跟現在時間比對。要注意這不是「前端 token 跟後端比」,而是前後端各自拿同一份expiration,各自跟自己手上的時間比——兩邊互不通訊,只是剛好比的是同一個數字。前端這一比只是「別浪費一次注定失敗的請求」的 UX 優化,不是安全機制;真正決定放不放行的,永遠是後端驗證簽章跟過期時間那一關
附帶一個技術細節:標準 JWT 會把過期時間放在 payload 的
expclaim 裡,後端解簽章時直接讀出來就好;這個專案額外再回一個expiration欄位,是為了讓前端完全不用解 JWT payload。這是一個刻意的封裝設計——後端保留隨時調整 JWT 內部結構的自由,前端也不會因為讀了 payload 而對它產生隱形依賴:expiration對前端來說只是 API 回傳的一個欄位,不是「前端懂了 JWT 裡面裝什麼」。
這樣算不算「保持登入」?算,但只有一半
拆開角色之後,會發現「保持登入」其實可以分成兩種完全不同的能力,這個專案只做到第一種:
- 做到的(session persistence,工作階段持久化):使用者重新整理頁面、關掉分頁、隔天再打開網站,只要 token 還沒過期,App 啟動時的檢查邏輯就會直接沿用 localStorage 裡的舊 token,讓使用者維持登入狀態——這是靠「一顆效期夠長的 token + 持久化儲存」撐出來的
- 沒做到的(silent renewal,無感續期):一旦
expiration那個時間點真正到了,不管使用者當下多活躍、正在操作到一半,下一次打 API 就是直接被判定過期。沒有任何機制會在背景默默換一新 token 延續下去——這種「無感延續」才是一般人講「保持登入」時真正期待的效果,而它需要額外的 refresh 機制才做得到
這兩個詞值得刻意分開記:session persistence 靠儲存就能達成,silent renewal 才需要 refresh 機制——這個專案有前者、沒有後者。
這個落差看程式碼會更清楚:仔細看 response interceptor 處理 401 的地方,會發現一個很寫實的細節——當初確實把「用 refresh_token 換新 access_token、排隊重試失敗請求」這整套邏輯寫出來過,寫法跟前面 chia1104 文章那張流程圖幾乎一致,但這段程式碼整段被註解掉,實際運作的行為只是「401 就直接登出、導回登入頁」。理論上想過、也寫過,只是沒有真正跑在線上——通常不是設計錯了,而是 refresh 排隊邏輯的邊界情況(多個請求同時 401、refresh token 也過期)比想像中難測乾淨,時程壓力下先上「401 直接登出」這個穩定版本,剩下的就無限期擱置。
順帶一提一個容易誤導人的小細節:登入畫面上的「記住我」核取方塊,其實跟 token 有沒有存起來完全無關——不管有沒有勾選,
setToken()都無條件會把 token 寫進 localStorage。這個核取方塊真正影響的,只是下次打開登入頁時帳號欄位要不要幫你預填。如果只看 UI,很容易誤以為「保持登入」是靠這個核取方塊控制的,但實際上它完全不參與這件事。
這剛好印證了 Gist 那篇文章的核心論點:JWT 的「無狀態」優勢,需要額外的狀態管理機制才能安全撤銷/刷新,而這套機制的實作與維護成本,很容易在真實專案的時程壓力下被犧牲掉,最後留下的是一個「效期內免重新登入,但效期一到就得整個重來」的系統——理論上有 refresh 設計,實際上從未真正運作過。
說到底,這個系統的登入壽命就是 token 的效期,一秒不多不少:在時間內做任何事(reload、關分頁、隔天再打開)都能保持登入,壽命一到,不管使用者在做什麼,就是結束。
結論與觀察
這篇 Gist 的價值不在於「JWT 是不好東西」這種一刀切的結論,而在於提醒我們:技術選型要對應到它原本被設計要解決的問題。JWT 被設計來處理短生命週期、一次性、跨服務的信任傳遞問題;而「讓使用者維持登入」這件事,傳統的 cookie session 其實一直都做得比較好、也比較好維護。
讀完兩篇之後,補充兩個比較落地的觀察:
- 選 JWT 的理由如果是「session 設定太麻煩」,那多半是被 Node.js 生態的模組化嚇到,而不是 session 本身真的難用。其他主流框架的 session 大多是開箱即用等級。
- 如果未來要做無密碼登入或更現代的驗證方式,Passkeys / WebAuthn 反而是更值得研究的方向,跟這篇文章談的「session vs JWT」是互補而非互斥的議題。
下次在專案裡看到「要不要用 JWT 做登入」這個選擇時,值得先停下來想一句:我需要的到底是「驗證身份的短期憑證」,還是「使用者的登入狀態」?這兩者用同一個工具解決,很可能就是問題的起點。