Web Day 12 JWT 認證:登入與 token 驗證
執行需求:CPU 可跑。昨天完成了註冊流程,密碼以 argon2id 雜湊儲存;今天進入「登入」與「token 驗證」。你會學到 JWT(JSON Web Token)的三個組成部分(header、payload、signature)、exp 與 iat 這類標準 claim 的意義、access token 與 refresh token 的分工,並用 PyJWT 2.10 寫出完整的「登入端點」、「刷新 token 端點」、「需登入的端點範例」。這套認證機制會是 Web Day 13「OAuth2 與角色授權」的基礎。
引言
註冊只解決了「使用者怎麼加入系統」,但每次使用者要查詢自己的資料、修改設定、刪除訂單,伺服器怎麼知道「這個請求真的是 Alice 送來的」?最直覺的做法是用 session:伺服器保留一份「Alice 已登入」的記錄,使用者帶一個 session id 來對照。但這套機制有兩個限制:第一,伺服器必須儲存所有使用者的 session 狀態,部署到多台機器時需要共享儲存(例如 Redis);第二,對純 API 場景(特別是 mobile app 或 SPA),cookie-based session 容易受到 CSRF 攻擊,需要額外防護。
JWT(JSON Web Token)是另一條路。它把「已登入」的狀態編碼進 token 本身:伺服器簽發一段包含使用者資訊的 JSON,用祕密簽名後交給前端;之後前端每次請求都帶這段 token,伺服器只要驗證簽名就能確認「這個 token 是我發的、內容沒被改過」。伺服器不需要存任何 session 狀態,這就是「無狀態」(stateless)認證的核心概念。
今天目標有四個:第一,理解 JWT 的結構與標準 claim;第二,用 PyJWT 2.10 實作「簽發 access token」「簽發 refresh token」「驗證 token」三個工具函式;第三,建立「登入端點」驗證密碼並回傳 token;第四,建立「需登入的端點範例」示範如何從 token 取出目前使用者。整篇文章的範例沿用昨天的 User 模型與昨天的 argon2id 密碼驗證。
JWT 的三個部分
一個 JWT 看起來像這樣:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJleHAiOjE3MzAwMDB9.signature_part。它由三段用 . 分隔的字串組成:
第一段是 header,包含兩件事:簽章演算法(這裡是 HS256)與 token 類型(JWT)。Header 會被 Base64URL 編碼(不是加密,只是編碼)後放在第一段,內容是可以讀出來的,但內容裡沒有敏感資訊。
第二段是 payload(又稱 claims),是我們要塞進 token 的資料。常見的標準 claim 有:sub(subject,這個 token 代表誰,例如 user id)、iat(issued at,簽發時間,Unix timestamp)、exp(expiration,過期時間,Unix timestamp)、iss(issuer,簽發者)、aud(audience,受眾)。除了標準 claim,你也可以放自訂欄位(例如 email、role)。Payload 也是 Base64URL 編碼,內容一樣可讀,所以絕對不要把密碼、信用卡號這類敏感資料放進去。
第三段是 signature,這才是 JWT 安全性的核心。簽章的計算方式是:把第一段與第二段用 . 接起來,用 header 指定的演算法(例如 HS256)+ 一個祕密字串計算 HMAC。接收端驗證時,只要拿同樣的祕密重算簽章,比對結果是否一致,就能確認「這個 token 是用這個祕密簽的、內容沒被竄改」。如果有人改了 payload 的內容,簽章就對不上,伺服器會拒絕這個 token。
這個設計的關鍵性質是「無狀態」:伺服器簽發 token 之後不需要儲存任何紀錄,之後驗證時只需要「重算簽章」這一個動作。缺點是「無法即時撤銷」:使用者登出、密碼外洩、被盜用,伺服器無法讓已發出去的 token 立刻失效。常見的補救方法是「短時效 access token + 長時效 refresh token」這套分工,下一節會詳述。另一個值得注意的點是「演算法選擇」:HS256(HMAC + SHA-256)是對稱演算法,簽發與驗證用同一個 secret,速度快、實作簡單,適合單體應用;RS256(RSA + SHA-256)是非對稱,簽發用私鑰、驗證用公鑰,適合需要把驗證交給第三方服務(例如 API gateway 只想驗 token、不想有簽發權限)的場景。今天的範例用 HS256,因為我們的應用自己簽自己驗,不需要把私鑰分開管理;如果你之後要把驗證邏輯拆給另一個微服務,再考慮切到 RS256。
Access token 與 refresh token 的分工
Access token 是前端每次請求都要帶的憑證,它的設計目標是「驗證快、過期快」。驗證快是因為每個請求都要做;過期快是因為萬一被偷走,攻擊者的可用時間也短。實務上 access token 的有效期通常設為 15 分鐘到 1 小時。
Refresh token 是用來「換新 access token」的憑證,使用者登入時拿到 access token 的同時也拿到 refresh token,前端把 refresh token 存起來(例如 httpOnly cookie 或安全的儲存空間)。當 access token 過期,前端用 refresh token 打「刷新端點」,伺服器驗證 refresh token 有效就發新的 access token,使用者不需要重新輸入密碼。Refresh token 的有效期通常設為 7 天到 30 天,並且可以做成「單次使用」:每次刷新後舊的 refresh token 就失效,降低被盜用的風險。
這套分工解決了「無狀態」的最大問題:access token 過期時間短,萬一外洩也只能用 15 分鐘;refresh token 只在「刷新」這個特定端點使用,且只換一次就失效,攻擊者就算偷到也要在短時間內動作,否則就過期。多數資安事件(例如 token 被 log 出去、被前端不小心存到 localStorage 然後 XSS 偷走)的衝擊都能透過這套機制大幅降低。
安裝 PyJWT 並設計 token 工具
PyJWT 是 Python 處理 JWT 的官方套件,2025 年 7 月主流版本是 2.10。安裝:
uv add "pyjwt==2.10"
建立 app/tokens.py,把 token 的簽發與驗證集中在一個模組:
# app/tokens.py
# JWT 簽發與驗證工具
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from jwt.exceptions import (
ExpiredSignatureError,
InvalidTokenError,
)
# 祕密字串從環境變數讀取;正式環境用 secrets.token_urlsafe(32) 產生
import os
SECRET_KEY = os.environ.get("JWT_SECRET", "change-me-in-production")
ALGORITHM = "HS256"
# access token 有效期:30 分鐘
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# refresh token 有效期:14 天
REFRESH_TOKEN_EXPIRE_DAYS = 14
def _create_token(
subject: str,
expires_delta: timedelta,
token_type: str,
extra_claims: dict[str, Any] | None = None,
) -> str:
# 共用的 token 產生函式
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": subject,
"iat": now,
"exp": now + expires_delta,
"type": token_type,
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def create_access_token(
subject: str, extra_claims: dict[str, Any] | None = None
) -> str:
# 簽發 access token(短時效)
return _create_token(
subject=subject,
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
token_type="access",
extra_claims=extra_claims,
)
def create_refresh_token(subject: str) -> str:
# 簽發 refresh token(長時效)
return _create_token(
subject=subject,
expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
token_type="refresh",
)
def decode_token(token: str, expected_type: str) -> dict[str, Any]:
# 解碼並驗證 token;驗證失敗會拋出例外
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except ExpiredSignatureError:
raise ValueError("token 已過期")
except InvalidTokenError:
raise ValueError("token 無效")
if payload.get("type") != expected_type:
raise ValueError(f"token 型別錯誤:預期 {expected_type}")
return payload
這段有幾個關鍵設計:SECRET_KEY 從環境變數讀取,正式環境一定要用 secrets.token_urlsafe(32) 這種方式產生隨機字串,絕對不能寫死在程式裡。_create_token 是共用工具,把 sub、iat、exp、type 這四個標準欄位統一處理,並支援 extra_claims 讓不同類型 token 帶不同資訊(access token 可以帶 email、role;refresh token 通常只帶 sub)。decode_token 同時驗證簽章、過期時間、token 型別,三個都通過才回傳 payload,否則拋出 ValueError,這樣等一下端點可以統一接上昨天的錯誤處理。
登入端點
現在寫登入端點,把昨天的「使用者查詢 + 密碼驗證」與今天的「token 簽發」接起來:
# app/schemas.py(新增)
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
端點本體:
# app/routers/auth.py(新增登入端點)
from fastapi import APIRouter, Depends, status
from sqlmodel import Session, select
from app.db import get_session
from app.errors import UnauthorizedError
from app.models import User
from app.schemas import LoginRequest, TokenResponse
from app.security import verify_password
from app.tokens import create_access_token, create_refresh_token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(
payload: LoginRequest,
session: Session = Depends(get_session),
) -> TokenResponse:
# 1. 找使用者
user = session.exec(
select(User).where(User.email == payload.email)
).first()
# 2. 驗證密碼(使用者不存在也回 401,避免被用來探測帳號)
if user is None or not verify_password(
user.password_hash, payload.password
):
raise UnauthorizedError(
message="電子郵件或密碼錯誤",
)
# 3. 檢查帳號是否啟用
if not user.is_active:
raise UnauthorizedError(message="帳號已被停用")
# 4. 簽發 token
extra = {"email": user.email}
access_token = create_access_token(
subject=str(user.id), extra_claims=extra
)
refresh_token = create_refresh_token(subject=str(user.id))
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
)
這支端點有幾個關鍵設計:第一,找使用者與驗證密碼分開做,但「使用者不存在」與「密碼錯誤」回應同樣的 401,這樣攻擊者無法用「電子郵件是否存在」來探測帳號清單。第二,verify_password 用昨天的工具,自動處理 argon2id 驗證。第三,extra_claims 把 email 放進 access token,這樣需要顯示「目前使用者資訊」時不必再查資料庫(但敏感資料絕對不要放,例如密碼雜湊)。第四,refresh token 只放 sub 不帶其他資訊,因為它只用來「確認使用者是誰」,換新 access token 時所有業務資訊可以從資料庫讀最新值。
刷新 token 端點
前端在 access token 過期時,用 refresh token 換新:
# app/routers/auth.py(新增刷新端點)
from app.schemas import RefreshRequest
from app.tokens import decode_token, create_access_token
@router.post("/refresh", response_model=TokenResponse)
def refresh(
payload: RefreshRequest,
session: Session = Depends(get_session),
) -> TokenResponse:
# 1. 解碼並驗證 refresh token
try:
data = decode_token(payload.refresh_token, expected_type="refresh")
except ValueError as exc:
raise UnauthorizedError(message=str(exc))
user_id = int(data["sub"])
# 2. 確認使用者仍存在且啟用
user = session.get(User, user_id)
if user is None or not user.is_active:
raise UnauthorizedError(message="帳號不存在或已停用")
# 3. 發新 access token 與新的 refresh token
extra = {"email": user.email}
new_access = create_access_token(
subject=str(user.id), extra_claims=extra
)
new_refresh = create_refresh_token(subject=str(user.id))
return TokenResponse(
access_token=new_access,
refresh_token=new_refresh,
)
注意這裡每次刷新都發新的 refresh token,讓舊的失效(單次使用)。這樣即使舊的 refresh token 被偷走,攻擊者用它刷新一次後,正當使用者的下一次刷新就會失敗,會被前端抓到異常並提示重新登入。實務上「強制單次使用」會犧牲一點便利性(使用者同時在多個裝置登入會互相踢),但對安全敏感的系統(例如銀行)這是值得的折衷。
取得目前使用者的相依性
在 FastAPI 裡「需登入的端點」會透過相依性注入(Web Day 5)實現。我們把「從 token 取出目前使用者」這件事做成一個 dependency:
# app/dependencies.py
from typing import Annotated
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import Session, select
from app.db import get_session
from app.errors import UnauthorizedError
from app.models import User
from app.tokens import decode_token
# HTTPBearer 會自動處理 Authorization: Bearer <token> 的解析
bearer_scheme = HTTPBearer(auto_error=False)
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
session: Session = Depends(get_session),
) -> User:
# 從 Authorization header 取出 token,驗證後查詢使用者
if credentials is None or not credentials.credentials:
raise UnauthorizedError(message="缺少認證資訊")
try:
payload = decode_token(credentials.credentials, expected_type="access")
except ValueError as exc:
raise UnauthorizedError(message=str(exc))
user_id = int(payload["sub"])
user = session.get(User, user_id)
if user is None or not user.is_active:
raise UnauthorizedError(message="帳號不存在或已停用")
return user
# 用 Annotated 型別簡化呼叫端點的語法
CurrentUser = Annotated[User, Depends(get_current_user)]
HTTPBearer 是 FastAPI 內建的安全工具,自動解析 Authorization: Bearer <token> header。auto_error=False 是關鍵:讓我們自己決定「缺少 token」要回什麼錯誤(昨天的 UnauthorizedError),而不是預設的 403。get_current_user 是一個 dependency,端點只要在參數裡宣告 user: CurrentUser,FastAPI 就會自動呼叫它、把使用者物件注入端點。這套模式會在後續所有需要登入的端點用到。
需登入的端點範例
用一支「讀取自己資料」的端點示範如何使用:
# app/routers/users.py
from fastapi import APIRouter
from app.dependencies import CurrentUser
from app.models import User
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me")
def read_me(user: CurrentUser) -> dict:
# 因為 dependency 已經驗證過 token、查過使用者,這裡直接用
return {
"id": user.id,
"email": user.email,
"created_at": user.created_at.isoformat(),
}
端點本身一行就能拿到目前使用者,完全不需要處理 token 驗證、查資料庫的細節。這種設計的好處是把「認證的橫切關注點」從業務邏輯裡抽離:未來要改 JWT 換成 PASETO、或要把 token 存到 Redis 快取,都只需要改 app/dependencies.py 一支檔案,所有端點都不受影響。這也是為什麼我們堅持把相依性注入寫在獨立的 dependencies.py 模組,而不是把 get_current_user 直接寫在路由檔裡。
驗證整個流程
啟動服務(命令列):
uvicorn app.main:app --reload --port 8000
用 httpx 走完整流程:註冊 → 登入 → 讀自己 → token 過期 → 刷新:
# test_auth.py
import httpx
import time
BASE = "http://127.0.0.1:8000/api"
with httpx.Client(base_url=BASE, timeout=10.0) as client:
# 1. 註冊(昨天的端點)
r = client.post(
"/auth/register",
json={"email": "alice@example.com", "password": "MyP@ssw0rd-2025"},
)
print(f"註冊:{r.status_code}")
# 2. 登入
r = client.post(
"/auth/login",
json={"email": "alice@example.com", "password": "MyP@ssw0rd-2025"},
)
print(f"登入:{r.status_code}")
tokens = r.json()
access_token = tokens["access_token"]
refresh_token = tokens["refresh_token"]
# 3. 用 access token 讀自己
r = client.get(
"/users/me",
headers={"Authorization": f"Bearer {access_token}"},
)
print(f"讀自己:{r.status_code}", r.json())
# 輸出(範例):200 {'id': 1, 'email': 'alice@example.com', 'created_at': '...'}
# 4. 沒帶 token(401)
r = client.get("/users/me")
print(f"沒帶 token:{r.status_code}", r.json())
# 輸出(範例):401 {'error': {'code': 'UNAUTHORIZED', ...}}
# 5. 用 refresh token 換新
r = client.post(
"/auth/refresh",
json={"refresh_token": refresh_token},
)
print(f"刷新:{r.status_code}")
new_tokens = r.json()
print(f"新 access_token 前 30 字:{new_tokens['access_token'][:30]}...")
# 6. 用壞的 token(401)
r = client.get(
"/users/me",
headers={"Authorization": "Bearer invalid.token.here"},
)
print(f"壞 token:{r.status_code}", r.json())
# 輸出(範例):401 {'error': {'code': 'UNAUTHORIZED', ...}}
六個步驟涵蓋了完整的認證流程:註冊、登入、讀自己、未授權、刷新、錯誤 token。每一個錯誤都走昨天設計的 UnauthorizedError,回應結構一致。實務上你會想再加上「token 即將過期前主動刷新」的邏輯:前端在收到 401 時,先嘗試用 refresh token 換新一次,換成功就重發原本的請求;只有當 refresh 也失敗時才提示使用者重新登入。這套「靜默刷新」體驗對使用者很重要,否則每 30 分鐘就要重新登入會非常惱人。後續的 Web Day 27「前端認證」會把這套流程在前端完整實作。
常見錯誤與踩雷
第一個最常見的踩雷是「SECRET_KEY 寫死在程式裡」。如果 secret 被簽進版控(特別是 public repo),任何人拿到 repo 就能偽造任意 token,整套認證機制瞬間失效。正確做法是用環境變數注入,正式部署時用 secrets.token_urlsafe(32) 之類的工具產生隨機字串,並放在 secret 管理系統(Kubernetes Secrets、Doppler、AWS Secrets Manager 等)。
第二個是「exp 用本地時間」。JWT 的 exp 與 iat 必須是 Unix timestamp,並且要帶時區資訊(實務上統一用 UTC)。我們今天用 datetime.now(timezone.utc),PyJWT 會自動轉成 Unix timestamp。如果用 datetime.utcnow()(Python 3.12 之後已棄用)會產生 naive datetime,可能在不同時區的伺服器間造成時序錯亂。
第三個是「access token 有效期太長」。有人圖方便把 access token 設成「30 天有效」,這等於把 refresh token 的安全分層拿掉。一旦 token 從前端 log 或瀏覽器儲存空間洩漏,攻擊者有 30 天可以慢慢用。access token 應該維持在 15 分鐘到 1 小時,refresh token 才是長效的主戰場。
第四個是「把敏感資料放進 JWT payload」。Payload 只是 Base64URL 編碼,任何拿到 token 的人都能解碼讀出原始 JSON。我們今天只放 email,實務上連 email 都要評估(個資法規要求)。絕對不要把密碼、信用卡、身分證字號這類資料塞進去。
第五個是「忘記區分 access 與 refresh token 型別」。如果兩種 token 用同一個 SECRET_KEY 簽發但沒有 type 欄位區分,攻擊者拿到 refresh token 後可以當 access token 用,欺騙伺服器。我們今天用 type="access" / type="refresh" 區分,decode_token 會檢查型別不符就拒絕。
效能與實務提醒
JWT 驗證在每次請求都會做:解碼 + HMAC 計算 + 檢查 exp,這些是 CPU bound 但成本不高(微秒等級)。但「查資料庫」這個步驟(session.get(User, user_id))是 IO bound,是大多數 API 的瓶頸。如果你的 API 大量使用認證,每個請求都要查一次 User 表,可以考慮「短期快取使用者資訊」:把使用者物件放進 Redis,5 秒過期,可以擋掉大部分的查詢。
另一個效能議題是「token 的大小」。JWT 因為包含 header、payload、signature,通常 200–500 位元組。每次請求都帶在 header,相當於每次都多傳幾百位元組的網路成本。如果你的 API 是高频呼叫(例如每秒數萬次),可以把自訂欄位縮減到最少(甚至不放 email、role,需要時再查),token 可以縮到 100 位元組以下。
最後一個重要提醒:「logout 怎麼做」。JWT 的無狀態特性讓「主動撤銷」變困難。常見做法有三種:第一,讓前端直接丟掉 token(最簡單但伺服器無法感知);第二,維護一份「黑名單」(Redis 存被撤銷的 token 的 jti,每次驗證前檢查);第三,把短時效 access token + 強制刷新策略結合,讓舊 token 自然過期。第一種對多數應用已經足夠,第二、三種用在金融或醫療等高敏感場景。
小結
今天把昨天的「使用者模型 + 密碼驗證」延伸成完整的 JWT 認證系統。我們用 PyJWT 2.10 設計了三個 token 工具(access、refresh、decode),並透過 HTTPBearer dependency 把「從 token 取出使用者」封裝成 CurrentUser,端點只要宣告 user: CurrentUser 就能拿到已驗證的使用者物件。整個系統在「缺少 token」「token 無效」「token 過期」「使用者不存在」四種情境下都回 401 與昨天的統一錯誤結構,前端可以無歧義地判斷「該重新登入了」。明天我們要在這套基礎上加「角色」:讓不同使用者有不同的權限(管理者 vs 一般使用者),這就是 OAuth2 與角色授權的主題。
結語
今天完成了 JWT 認證的核心機制,明天進入「OAuth2 與角色授權」。你會學到 OAuth2 的 scope 概念如何應用在 FastAPI、SecurityScopes 的用法、如何把昨天的 User 擴充成支援角色(管理者 vs 客戶),以及如何在端點上宣告「需要管理者角色」。整個安全主題會在明天收尾,之後進入「品質」主題,開始寫 pytest 測試。
延伸資源
- PyJWT 官方文件(2.10,2025):
https://pyjwt.readthedocs.io/en/stable/ - JWT 官方介紹(RFC 7519,2025):
https://datatracker.ietf.org/doc/html/rfc7519 - FastAPI 安全工具(0.116,2025):
https://fastapi.tiangolo.com/tutorial/security/ - OWASP JWT 安全 cheat sheet(2025):
https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
留言
張貼留言