Auth 的問題不是「怎麼用框架的套件」
框架文件都有 auth 範例——NestJS 的 @UseGuards(JwtAuthGuard)、Spring 的 @PreAuthorize、FastAPI 的 Depends(get_current_user)。語法不難查。
難的是 auth 的設計決策:
- JWT 的 payload 要存什麼?不能存什麼?
- 用 JWT 還是 session cookie?這個決策背後的 trade-off 是什麼?
- Access token 過期了怎麼辦?refresh token 要怎麼設計才不會成為漏洞?
- guard 要放在 middleware 層還是 route handler 層?
這些問題的答案跟框架無關——它們是 auth 本身的設計問題,在 Express、FastAPI、Laravel 上全部適用。
JWT vs Session:stateless vs stateful 的 trade-off
這是 auth 設計的第一個決策點。
Session(stateful):
Client → POST /login → Server 建立 session,存在記憶體 / Redis → 回傳 session_id cookie
下一個 request → Cookie 帶 session_id → Server 去查 session store → 取得 user data
Server 需要維護 session store(Redis 或 DB)。好處是可以隨時強制 invalidate(登出就刪 session)。
JWT(stateless):
Client → POST /login → Server 簽發 JWT → Client 存在 localStorage 或 httpOnly cookie
下一個 request → Bearer token → Server 驗 signature,從 payload 取 user data,不查 DB
Server 不需要 session store。但 JWT 一旦簽發,在 exp 到期前無法強制 invalidate——除非你維護一份黑名單(那你又有狀態了)。
| JWT(stateless) | Session(stateful) | |
|---|---|---|
| Server 端狀態 | 無 | Redis / DB session store |
| 水平擴展 | 簡單(任一 pod 都能驗) | 需要 sticky session 或共用 Redis |
| 強制登出 | 難(需要黑名單) | 簡單(刪 session) |
| Payload 大小 | 每個 request 都帶 JWT | Cookie 只帶 session_id,輕量 |
| 適合場景 | API、microservice、多個子網域 | 同一域名的 web app,需要即時 revoke |
2026 年的主流選擇:API 用 JWT + refresh token;需要即時 revoke 的場景(金融、企業 SSO)用 session 或 JWT + Redis 黑名單。
JWT Payload 設計
JWT 的 payload 是 Base64 編碼,不是加密——任何人 decode 都能看到內容。
// ✅ 正確的 JWT payload
{
sub: "user_123", // subject — user ID
email: "alice@example.com",
roles: ["admin"], // 角色(用於 RBAC guard)
iat: 1714000000, // issued at
exp: 1714003600, // expiry(15 分鐘)
jti: "uuid-xxx" // JWT ID,用於黑名單時的 revoke key
}
// ❌ 不能放在 JWT 的東西
{
password: "...", // 絕對不行
credit_card: "...", // 敏感資料不放 payload
permissions: [ // 如果 permission 很多,JWT 會很大
"users:create", // 改成只存 roles,guard 查 DB 取 permissions
"posts:delete",
// ... 50 個 permission
]
}Access token 的 exp 設多短:15 分鐘是常見設定。越短越安全(洩漏的 token 失效快),但 UX 需要 refresh token 機制。
Refresh Token 輪換策略
Access token 短命(15 分鐘),但讓使用者每 15 分鐘重新登入是不能接受的。解法是 refresh token。
基本流程:
POST /auth/login
→ access_token (exp: 15m) + refresh_token (exp: 30d)
refresh_token 存到 DB,標記 user_id + token hash
access_token 過期後:
POST /auth/refresh
body: { refresh_token }
→ Server 驗 refresh_token:查 DB,確認存在且未 revoke
→ 回傳新的 access_token + 新的 refresh_token(舊的作廢)
Refresh token rotation 是關鍵:每次使用 refresh token 都換一個新的,舊的立刻作廢。好處是如果 refresh token 洩漏,攻擊者用了一次之後,合法 client 下次用舊的就會失敗——系統偵測到衝突,可以把這個 user 的所有 refresh token 全部 revoke。
// Refresh token DB 結構
interface RefreshToken {
id: string; // uuid
userId: string;
tokenHash: string; // bcrypt hash of the actual token
family: string; // 同一個 login session 的 family ID
isRevoked: boolean;
expiresAt: Date;
createdAt: Date;
}
async function refreshTokens(rawToken: string) {
const hash = hashToken(rawToken);
const stored = await RefreshToken.findOne({ where: { tokenHash: hash } });
if (!stored) throw new UnauthorizedException('invalid token');
if (stored.isRevoked) {
// 使用已 revoke 的 token = 可能洩漏,整個 family 全撤
await RefreshToken.update(
{ isRevoked: true },
{ where: { family: stored.family } }
);
throw new UnauthorizedException('token reuse detected');
}
if (stored.expiresAt < new Date()) throw new UnauthorizedException('token expired');
// 舊的 revoke,發新的
await stored.update({ isRevoked: true });
const newAccessToken = signJwt({ sub: stored.userId });
const newRefreshToken = await issueRefreshToken(stored.userId, stored.family);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}Guard Middleware 的放置位置
Guard 要驗的事:這個 request 有沒有合法的 access token?
// Express:middleware 層,放在 router 前
app.use('/api/admin', authenticate, adminOnly, adminRouter);
app.use('/api/users', authenticate, userRouter);
app.use('/api/public', publicRouter); // 不掛 authenticate
// authenticate middleware
export const authenticate = async (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload; // 把 user 掛到 req,下游可用
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
};Guard 只做驗證,不做授權:guard 確認你是誰(authentication),不決定你能做什麼(authorization)。authorization 的決策放在 RBAC guard 或 policy layer。
把 auth 邏輯封在 middleware,不要寫進 route handler:
// ❌ auth 邏輯進了 route handler
router.get('/users', async (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Unauthorized' });
const user = jwt.verify(token, secret);
// ... 業務邏輯
});
// ✅ route handler 只管業務邏輯
router.get('/users', authenticate, async (req, res) => {
// req.user 已由 middleware 注入
const users = await userService.findAll(req.user);
res.json(users);
});密碼 Hashing:bcrypt cost factor 的設定
import bcrypt from 'bcrypt';
const COST_FACTOR = 12; // 2^12 次 hashing iterations
// 儲存密碼
const hashedPassword = await bcrypt.hash(plaintext, COST_FACTOR);
// 驗證密碼
const isValid = await bcrypt.compare(plaintext, hashedPassword);Cost factor 怎麼選:目標是每次 hash 操作在你的生產機器上花約 200–300ms。太低(cost 8)= 暴力破解成本低;太高(cost 14)= 每次登入都卡 server 1 秒以上。
上線前跑一次 benchmark:
const { performance } = require('perf_hooks');
for (const cost of [10, 11, 12, 13]) {
const start = performance.now();
await bcrypt.hash('benchmark', cost);
console.log(`cost ${cost}: ${(performance.now() - start).toFixed(0)}ms`);
}用 argon2 也可以(password hashing competition 冠軍),API 類似但預設參數更強。
各框架的 Auth 整合點
| 框架 | Auth 中介層機制 | 常用套件 |
|---|---|---|
| Express | app.use(authenticate) middleware | jsonwebtoken + 自建 middleware |
| FastAPI | Depends(get_current_user) | python-jose / authlib |
| NestJS | @UseGuards(JwtAuthGuard) | @nestjs/passport + passport-jwt |
| Spring Boot | SecurityFilterChain | spring-security + jjwt |
| Laravel | auth:api middleware | tymon/jwt-auth 或 Sanctum |
這些是語法上的差異。設計上的 token payload、refresh rotation、guard placement 的決策,在所有框架裡是一樣的。
