JWT 是什麼?運作原理完整解析

Published November 14, 2024 by 徐培鈞
基礎概念

上一篇文章中,我們討論了一個問題:為什麼不該把使用者資料直接存在 Cookie 裡?

答案是:不安全。Cookie 是存在使用者電腦上的,使用者可以看到內容,也可以修改。

如果你把 user_id=123 存在 Cookie,使用者可以直接改成 user_id=456,假裝是別人。

所以我們介紹了 Session 機制:

  • Cookie 只存一個隨機產生的 Session ID
  • 實際的使用者資料存在伺服器端
  • 伺服器用 Session ID 去查對應的資料

這樣一來,使用者就算亂改 Session ID,伺服器也查不到對應的資料,就不會有安全問題。

Session 機制解決了「資料被竄改」的問題,但當網站規模變大的時候,新的問題就來了。

Session 機制有什麼問題?

問題一:伺服器要存每個人的資料

在 Session 機制中,伺服器需要為「每一個登入的使用者」儲存一份資料。

假設你的網站有 100 萬個活躍用戶同時在線上,伺服器就要存 100 萬筆資料。這會帶來儲存空間的壓力,以及查詢效能的問題。

問題二:多台伺服器之間要同步

當網站流量變大,我們會用多台伺服器來分擔工作。但這會產生一個問題:Session 資料存在哪台伺服器?

使用者登入  請求被導向伺服器 A  伺服器 A 產生 Session,存下資料


使用者點擊商品頁  請求被導向伺服器 B  伺服器 B 查不到資料(因為資料在 A)

使用者明明剛登入,卻被當作「沒有登入」。

問題三:跨服務驗證很麻煩

現代的系統架構,常常會拆分成多個獨立的服務(使用者服務、訂單服務、支付服務等)。

假設使用者在「使用者服務」登入了,Session 資料存在這個服務裡。當使用者要下單時,「訂單服務」要怎麼知道這個使用者是誰?它必須去問「使用者服務」:

sequenceDiagram
    participant U as 使用者
    participant O as 訂單服務
    participant A as 使用者服務

    U->>O: 我要下單(帶著 Session ID)
    O->>A: 這個 Session ID 是誰的?
    A->>O: 這是 user_123,他是 VIP 會員
    O->>U: 好的,已經幫你建立訂單

每次驗證身份都要跨服務查詢,增加延遲、增加負擔,而且如果「使用者服務」掛了,其他服務也無法驗證身份。

換個思路:把身份資訊帶在身上

Session 機制的核心是:伺服器存使用者的身份資訊,使用者只帶著識別碼

但如果我們換個思路呢?

讓使用者直接把身份資訊帶在身上,伺服器不用存任何東西。

但這樣有個問題:如果身份資訊是存在使用者端,使用者不就可以自己修改嗎?例如把 user_id=123 改成 user_id=456,假裝是別人。這不就是當初我們改用 Session 的原因嗎?

沒錯,如果「只是」把身份資訊存在使用者端,確實會有這個問題。

所以我們需要一個機制,讓伺服器可以驗證「這份身份資訊是不是我發的、有沒有被改過」。

這個機制就是 JWT(JSON Web Token)。

JWT 是什麼?

JWT 是一個 Token(令牌)。

Token 是什麼?你可以把它想成一張「通行證」——拿著它,就能證明你是誰、有什麼權限。

JWT 這個 Token 的內容包含兩個部分:

  • 身份資訊:例如 user_id=123, role=member
  • 簽名:用來驗證身份資訊有沒有被竄改

最後會組合成一個字串,交給使用者存在瀏覽器裡。

等等,這個「簽名」是什麼?它要怎麼驗證身份資訊有沒有被竄改?

什麼是簽名?

伺服器有一組「密鑰」,你可以把它想成一組只有伺服器知道的密碼,例如 my-super-secret-key-12345

當伺服器要產生簽名的時候,會把「身份資訊」和「密鑰」丟進一個雜湊函數,算出一串結果:

簽名 = 雜湊(身份資訊, 密鑰)

算出來的簽名會是一串看起來像亂碼的字串,例如 dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

這個雜湊函數有一個特性:同樣的身份資訊 + 同樣的密鑰,一定會算出同樣的簽名

JWT 的組成方式

有了簽名之後,伺服器會把「身份資訊」和「簽名」組合起來,變成一個字串。

組合方式很簡單:用「.」(點)連接起來。

JWT = 編碼(身份資訊).簽名

這裡的「編碼」不是加密,只是把資料轉換成另一種格式(Base64),任何人都可以解碼回來看到原本的內容

舉個例子,最後產生的 JWT 可能長這樣:

eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJtZW1iZXIifQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r

前半段是編碼後的身份資訊,後半段是簽名,中間用「.」分開。

flowchart LR
    subgraph 前半段
        I[身份資訊] --> E[編碼] --> F[eyJ1c2VyX2lk...]
    end
    
    subgraph 後半段
        I2[身份資訊] --> H[雜湊]
        K[密鑰] --> H
        H --> S[dBjftJeZ4CVP...]
    end
    
    F --> J[用 . 連接]
    S --> J
    J --> JWT[eyJ1c2VyX2lk....dBjftJeZ4CVP...]

伺服器產生 JWT 後,會交給使用者,使用者就把它存在瀏覽器裡。

伺服器怎麼驗證?

當使用者帶著 JWT 回來,伺服器要怎麼驗證?

因為 JWT 是用「.」分隔的,伺服器可以輕易地把它拆開:

  1. 拆開 JWT:用「.」分割,拿到「身份資訊」和「使用者帶來的簽名」
  2. 重新計算簽名:用拿到的「身份資訊」加上伺服器自己的「密鑰」,重新算一次簽名
  3. 比對簽名:比較「自己算出來的簽名」和「使用者帶來的簽名」是否一致
flowchart LR
    subgraph 拆開 JWT
        J[JWT] --> I[身份資訊]
        J --> OS[原簽名]
    end
    
    subgraph 重新計算
        I --> H[雜湊]
        K[密鑰] --> H
        H --> NS[新簽名]
    end
    
    OS --> C[比對]
    NS --> C
    C --> R{是否一致?}

如果一致,就表示這份身份資訊確實是伺服器發的,而且沒有被改過。

如果有人試圖竄改?

如果使用者試圖把 user_id=123 改成 user_id=456,會發生什麼事?

身份資訊變了,但他算不出新的簽名(因為他不知道密鑰),所以只能帶著舊的簽名。伺服器重新計算後,發現簽名對不上,就會拒絕這個請求。

這就是 JWT 和「直接把身份資訊存在 Cookie」的關鍵差別:多了簽名機制,讓伺服器可以驗證身份資訊有沒有被竄改。

JWT 是怎麼運作的?

讓我們來看看,使用 JWT 的登入流程是什麼樣子。

流程圖

sequenceDiagram
    participant U as 使用者
    participant B as 瀏覽器
    participant S as 伺服器

    Note over U,S: 登入階段
    U->>B: 輸入帳號密碼,點擊登入
    B->>S: POST /login(帳號、密碼)
    Note over S: 驗證成功,產生 JWT
    S->>B: 回應 + JWT
    B->>U: 顯示「登入成功」
    Note over B: 瀏覽器儲存 JWT

    Note over U,S: 後續請求(帶著 JWT)
    U->>B: 點擊「我的帳戶」
    B->>S: GET /account + Authorization: Bearer <JWT>
    Note over S: 驗證 JWT 簽名,從中讀取使用者資料
    S->>B: 回應帳戶頁面
    B->>U: 顯示帳戶資訊

步驟一:使用者登入

使用者在登入頁面輸入帳號密碼,瀏覽器發送請求:

POST /login HTTP/1.1
Host: www.example.com
Content-Type: application/json

{
  "username": "john",
  "password": "secret123"
}

步驟二:伺服器驗證成功,產生 JWT

伺服器檢查帳號密碼正確後,會產生 JWT。

前面我們把 JWT 簡化成「身份資訊 + 簽名」兩個部分,但實際上 JWT 由三個部分組成:

Header.Payload.Signature
  • Header(標頭):說明這是什麼類型的 Token,用了什麼簽名演算法
  • Payload(內容):就是我們說的「身份資訊」,例如使用者 ID、角色等
  • Signature(簽名):用來驗證身份資訊沒有被竄改

伺服器會依序組裝這三個部分:

組裝 Header

Header 用來說明「這個 Token 是什麼類型」和「用什麼演算法簽名」:

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg:簽名演算法,這裡用的是 HS256
  • typ:Token 類型,就是 JWT

組裝 Payload

Payload 放的是使用者的身份資訊:

{
  "user_id": 123,
  "username": "john",
  "role": "member",
  "exp": 1702900000
}
  • user_idusernamerole:使用者的身份資訊
  • exp:過期時間(Expiration Time),這是一個 Unix 時間戳,表示這個 JWT 什麼時候會失效

計算 Signature

還記得前面說的簽名公式嗎?

簽名 = 雜湊(身份資訊, 密鑰)

實際上,JWT 的簽名是這樣計算的:

Signature = 雜湊( Base64(Header) + "." + Base64(Payload) , 密鑰 )

伺服器會把 Header 和 Payload 都納入簽名計算,這樣如果有人改了其中任何一個,簽名就會對不上。

4組合成 JWT

最後,把三個部分都用 Base64 編碼,然後用「.」連接起來:

Base64(Header).Base64(Payload).Signature

最後產生的 JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiam9obiIsInJvbGUiOiJtZW1iZXIiLCJleHAiOjE3MDI5MDAwMDB9.sQh4J8...

看起來是一串亂碼,但其實就是三個部分用「.」連起來而已。

步驟三:伺服器把 JWT 回傳給瀏覽器

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "success",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiam9obiIsInJvbGUiOiJtZW1iZXIiLCJleHAiOjE3MDI5MDAwMDB9.sQh4J8..."
}

注意:這裡 JWT 是放在 Response Body 裡,不是用 Set-Cookie。

兩種傳遞方式比較

當然,也可以用 Cookie 來傳遞 JWT,這是另一種做法。兩種方式各有優缺點:

優點不受 Cookie 的跨域限制
缺點前端要自己處理儲存和攜帶
優點瀏覽器自動處理
缺點受到 Cookie 的各種限制

這篇文章先用「Response Body」的方式來說明。

步驟四:瀏覽器儲存 JWT

前端收到 JWT 後,要把它存起來。

瀏覽器的儲存空間

除了 Cookie 之外,瀏覽器還有其他儲存空間可以用:

  • localStorage:關閉瀏覽器後還在
  • sessionStorage:關閉瀏覽器就消失
  • 記憶體(變數):重新整理頁面就消失(最安全,但最不方便)
// 例如存在 localStorage
localStorage.setItem('token', response.token);

步驟五:後續請求,帶著 JWT

當使用者點擊「我的帳戶」,前端會從儲存的地方取出 JWT,放在 HTTP Header 裡:

GET /account HTTP/1.1
Host: www.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Authorization Header 與 Bearer

這裡用的是 Authorization 這個 Header,格式是 Bearer <token>

這是 OAuth 2.0 規範定義的標準格式,大部分的 API 都遵循這個慣例。

步驟六:伺服器驗證 JWT

伺服器收到請求後,會做這些事:

  1. 從 Header 取出 JWT
  2. 把 JWT 拆成三部分(Header、Payload、Signature)
  3. 驗證簽名:用同樣的密鑰,對 Header 和 Payload 重新計算簽名,比對是否一致
  4. 檢查是否過期:看 Payload 裡的 exp 是否已經超過現在的時間
  5. 讀取 Payload:如果驗證通過,就從 Payload 取得使用者資料
驗證成功  知道這是 user_id=123  john,角色是 member


驗證失敗  拒絕請求,回傳 401 Unauthorized

重點:伺服器完全不需要儲存任何資料。

使用者的資訊都在 JWT 的 Payload 裡,伺服器只需要驗證簽名、讀取內容就好。

JWT 解決了什麼問題?

讓我們回頭看看,Session 機制的三個問題,JWT 是怎麼解決的:

問題一:伺服器要存每個人的資料

Session 做法:伺服器為每個使用者存一份資料

JWT 做法:伺服器不存資料,使用者自己帶著資料來

這樣伺服器就不需要維護龐大的 Session 儲存空間,也不需要擔心查詢效能。

問題二:多台伺服器之間要同步

Session 做法:需要 Session 黏著、Session 複製、或集中式儲存

JWT 做法:每台伺服器都可以獨立驗證 JWT(只要它們用同樣的密鑰)

使用者登入  任意一台伺服器產生 JWT


使用者點擊商品頁  請求被導向任意一台伺服器  都能驗證 JWT,都能讀取使用者資料

因為使用者的資料就在 JWT 裡,不需要去查任何地方。

問題三:跨服務驗證很麻煩

Session 做法:其他服務要問「使用者服務」才能知道這是誰

JWT 做法:任何服務都可以自己驗證 JWT

sequenceDiagram
    participant U as 使用者
    participant O as 訂單服務

    U->>O: 我要下單(帶著 JWT)
    Note over O: 自己驗證 JWT,讀取使用者資料
    O->>U: 好的,已經幫你建立訂單

不需要跨服務查詢,每個服務都是獨立的。

只要所有服務都知道同一個密鑰,就能驗證 JWT 的真偽。

JWT vs Session 比較

Session 機制伺服器端
JWT使用者端(JWT 本身)
Session 機制需要為每個使用者儲存資料
JWT不需要儲存
Session 機制需要同步機制
JWT每台都能獨立驗證
Session 機制需要查詢「使用者服務」
JWT每個服務都能自己驗證
Session 機制刪除伺服器端的資料即可
JWT比較麻煩
Session 機制直接改伺服器端的資料
JWT需要重新發一個 JWT

小結

現在你知道了:

  1. Session 機制的痛點:伺服器要存資料、多台伺服器要同步、跨服務驗證麻煩
  2. JWT 的核心概念:把身份資訊帶在身上,用簽名防止偽造
  3. 簽名的原理:伺服器用密鑰計算簽名,驗證時重新計算比對
  4. JWT 怎麼運作:簽發 → 儲存 → 攜帶 → 驗證
  5. JWT 解決的問題:不用儲存、可獨立驗證、適合分散式架構