症狀: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 認證機制」系列的延伸:

  1. 會員資料不見?這不是我沒要求,是外包商沒做完整的 OAuth 2.0 — Token 過期問題的發現與方案選擇
  2. Strapi v5 升級後 /auth/refresh 回傳 403 的根因分析(本文)— 實作 refresh token 後踩到的版本升級陷阱