在上一篇文章中,我們討論了一個問題:為什麼不該把使用者資料直接存在 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 是用「.」分隔的,伺服器可以輕易地把它拆開:
- 拆開 JWT:用「.」分割,拿到「身份資訊」和「使用者帶來的簽名」
- 重新計算簽名:用拿到的「身份資訊」加上伺服器自己的「密鑰」,重新算一次簽名
- 比對簽名:比較「自己算出來的簽名」和「使用者帶來的簽名」是否一致
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:簽名演算法,這裡用的是 HS256typ:Token 類型,就是 JWT
組裝 Payload
Payload 放的是使用者的身份資訊:
{
"user_id": 123,
"username": "john",
"role": "member",
"exp": 1702900000
}
user_id、username、role:使用者的身份資訊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,這是另一種做法。兩種方式各有優缺點:
| 傳遞方式 | 優點 | 缺點 |
|---|---|---|
| Response Body + 前端儲存 | 不受 Cookie 的跨域限制 | 前端要自己處理儲存和攜帶 |
| Set-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
伺服器收到請求後,會做這些事:
- 從 Header 取出 JWT
- 把 JWT 拆成三部分(Header、Payload、Signature)
- 驗證簽名:用同樣的密鑰,對 Header 和 Payload 重新計算簽名,比對是否一致
- 檢查是否過期:看 Payload 裡的
exp是否已經超過現在的時間 - 讀取 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 本身) |
| 伺服器儲存需求 | 需要為每個使用者儲存資料 | 不需要儲存 |
| 多台伺服器 | 需要同步機制 | 每台都能獨立驗證 |
| 跨服務驗證 | 需要查詢「使用者服務」 | 每個服務都能自己驗證 |
| 登出機制 | 刪除伺服器端的資料即可 | 比較麻煩 |
| 資料修改 | 直接改伺服器端的資料 | 需要重新發一個 JWT |
小結
現在你知道了:
- Session 機制的痛點:伺服器要存資料、多台伺服器要同步、跨服務驗證麻煩
- JWT 的核心概念:把身份資訊帶在身上,用簽名防止偽造
- 簽名的原理:伺服器用密鑰計算簽名,驗證時重新計算比對
- JWT 怎麼運作:簽發 → 儲存 → 攜帶 → 驗證
- JWT 解決的問題:不用儲存、可獨立驗證、適合分散式架構