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 都帶 JWTCookie 只帶 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 中介層機制常用套件
Expressapp.use(authenticate) middlewarejsonwebtoken + 自建 middleware
FastAPIDepends(get_current_user)python-jose / authlib
NestJS@UseGuards(JwtAuthGuard)@nestjs/passport + passport-jwt
Spring BootSecurityFilterChainspring-security + jjwt
Laravelauth:api middlewaretymon/jwt-auth 或 Sanctum

這些是語法上的差異。設計上的 token payload、refresh rotation、guard placement 的決策,在所有框架裡是一樣的。


延伸閱讀