症狀:App 開啟 15 分鐘後被強制登出
使用者回報一個詭異的問題:App 正常使用一段時間後,突然被強制登出。重新登入後又好了,但過一陣子又被踢出去。
時間規律很明確——正好 15 分鐘。這恰好是我們 JWT 的過期時間。
直覺反應是去查前端的 token refresh 邏輯:interceptor 有沒有正確攔截 401?refresh token 有沒有正確儲存?重試機制有沒有 bug?
查了一輪,全部正常。
錯誤的除錯方向
這個問題前前後後花了不少時間,方向一直在前端打轉:
| 排查方向 | 結果 |
|---|---|
| Flutter interceptor 邏輯 | 正常,有攔截 401 並觸發 refresh |
| Token 儲存機制(SecureStorage) | 正常,refresh token 有正確保存 |
| JWT 過期時間設定 | 確認是 15 分鐘,符合預期 |
| 網路連線問題 | 排除,其他 API 都正常 |
每個環節看起來都沒問題,但結果就是 refresh 失敗。問題出在哪?
轉折點:看 HTTP Status Code
直到有人(或者說,終於有人)去看了 /auth/refresh 實際回傳的 HTTP status code:
# JWT 過期後呼叫 refresh
curl -X POST https://api.example.com/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken": "valid-refresh-token-here"}'
# 預期:200 OK(新的 JWT)
# 實際:403 Forbidden
403,不是 401。
這是關鍵線索。401 代表「沒有認證」,403 代表「認證了但沒有權限」。回傳 403 意味著 Strapi 的權限系統主動擋下了這個請求——在 refresh handler 有機會執行之前。
根因:Strapi v5.31+ 的隱性行為變更
追查後發現,Strapi v5.31+ 將 /auth/refresh 和 /auth/logout 從「需要自行註冊的路由」變成了內建路由。
這個變更本身合理,問題在於:內建路由預設會經過 Strapi 的權限系統驗證 JWT。
這造成了一個邏輯矛盾:
使用者的 JWT 過期了
→ App 呼叫 /auth/refresh 想換新的 JWT
→ Strapi 權限系統先檢查 JWT 是否有效
→ JWT 已過期,權限系統回傳 403
→ refresh handler 根本沒機會執行
→ App 收到 403,判定為認證失敗
→ 強制登出
用白話說:你拿著過期的門禁卡去保全室想換新卡,但保全在你到達櫃檯之前就因為你的卡過期把你攔在門外了。
為什麼之前沒這個問題?
在 v5.31 之前,/auth/refresh 是我們自行註冊的路由,當時的程式碼長這樣:
plugin.routes["content-api"].routes.push({
method: "POST",
path: "/auth/refresh",
handler: "auth.refresh",
config: {
auth: false, // 繞過權限系統
},
});
auth: false 明確告訴 Strapi:這條路由不需要 JWT 驗證。升級後,內建路由取代了我們的自訂路由,但沒有帶上 auth: false 這個設定。
修復:補回 auth: false
修復方式是遍歷 plugin 的內建路由,找到 /auth/refresh 和 /auth/logout,補上 auth: false:
// 取得 content-api 的所有路由
const routes = plugin.routes["content-api"].routes;
for (const route of routes) {
if (route.path === "/auth/refresh" || route.path === "/auth/logout") {
route.config = {
...route.config,
auth: false, // 繞過權限系統,讓 refresh token 自行驗證
};
}
}
為什麼 /auth/logout 也需要?因為登出時 JWT 同樣可能已經過期,如果被權限系統攔下,使用者連登出都做不到(refresh token 不會被撤銷,形成安全隱患)。
這兩條路由不需要 JWT 驗證的原因是一致的:它們靠 refresh token(而非 JWT)來驗證身份。
反思:為什麼除錯方向會走偏
回頭看,這個 bug 其實不難修,但找到它卻花了很多時間。核心原因是除錯時犯了一個常見錯誤:
症狀在前端,就一直在前端找。
正確的做法應該是:在確認前端邏輯沒問題後,立刻去看後端實際回傳了什麼。如果第一時間就看到 403 而非 401,就能馬上意識到問題出在 Strapi 的權限層,而不是 refresh 邏輯本身。
版本升級的除錯盲區
另一個讓這個問題難以發現的原因是:它是版本升級帶來的隱性行為變更。
程式碼沒改、設定沒改、功能沒改——只是 Strapi 升級後,原本由我們註冊的路由變成了內建路由,auth: false 的設定就這樣消失了。這種「升級後行為悄悄改變」的問題,不會出現在 error log 裡,也不會在 CI 跑出紅燈。
教訓:版本升級後,要特別檢查自訂擴充(extensions)是否被內建功能覆蓋。 尤其是 Strapi 這種 plugin 架構,當框架把原本需要手動註冊的路由改為內建時,你的自訂設定可能會被無聲地吞掉。
系列文章
本文是「Token 認證機制」系列的延伸:
- 會員資料不見?這不是我沒要求,是外包商沒做完整的 OAuth 2.0 — Token 過期問題的發現與方案選擇
- Strapi v5 升級後 /auth/refresh 回傳 403 的根因分析(本文)— 實作 refresh token 後踩到的版本升級陷阱
