前言:帳密明明正確,卻回 500

某天收到回報:使用者從手機點開官網登入,帳號密碼都對,卻跳出 Internal Server Error。換其他瀏覽器再登入就成功了。

「帳密正確卻 500」這種錯誤特別惱人,因為它不是身分驗證的問題,而是驗證通過之後才出事。本文記錄怎麼從一行日誌追到根因,以及背後三個值得每個後端工程師記住的設計教訓。


從日誌追根因:三條線索

先分清楚登入失敗的兩種回應:帳密錯誤會回 400 Invalid identifier or password;而這次是 500。光這一點就暗示——帳密其實是對的,問題出在登入流程的後半段。

線索一:500 只在帳密正確時出現

撈出該帳號當天的登入紀錄,發現 500 全部集中在某個時段,之後突然出現一次成功,再之後才是幾筆「帳密錯誤」的 400。換句話說,那串 500 本身就證明了帳密一直是對的——錯的不是驗證。

線索二:那行 PostgreSQL 錯誤訊息

日誌裡每筆 500 都附帶同一句資料庫錯誤:

insert into "refresh_tokens" (..., "user_agent") values (...)
  - value too long for type character varying(255)

真相浮現:登入驗證通過後,後端要寫一筆 refresh token 進資料庫,其中 user_agent 欄位是 varchar(255),而這次要寫入的值超過 255 字元,PostgreSQL 直接拒絕,未被攔截的例外往上冒成了 500。

線索三:為什麼有時成功、有時失敗

成功那次寫進資料庫的 user_agent 長度是 128;失敗那幾次的值都超過 255。差別就在 User-Agent 字串的長度——而它由「使用者用什麼瀏覽器」決定。


根本原因:varchar(255) 塞不下 in-app 瀏覽器的 UA

寫入 refresh_tokens 的欄位中,token 是固定長度的隨機字串、expires_at 是時間、device_type 是短列舉、ip_address 也很短。唯一長度會劇烈變動、且可能爆掉的,只有 user_agent

兇手是 in-app 瀏覽器。當使用者從 Facebook、Instagram、LINE 這類 App 的內建瀏覽器開網站時,User-Agent 會被接上一長串識別碼:

Mozilla/5.0 (iPhone; ...) ... Mobile/15E148
[FBAN/FBIOS;FBAV/466.0.0.30.107;FBDV/iPhone15,2;FBMD/iPhone;
 FBSN/iOS;FBSV/17.5.1;FBLC/zh_TW;FBOP/5;IABMV/1]

這種 UA 動輒 300~400 字元,輕鬆超過 255。所以使用者「跳出 App、改用 Safari」再登入就成功了——不是因為重打了密碼,而是因為換了一個 UA 較短的瀏覽器。500 與否,完全由 UA 長度決定,跟密碼對錯無關。

sequenceDiagram
    participant C as 前端(in-app 瀏覽器)
    participant A as 登入 API
    participant DB as PostgreSQL
    C->>A: POST /login(帳密正確 + 超長 UA)
    A->>A: ① 驗證帳密 ✅
    A->>DB: ② INSERT refresh_token(含 user_agent)
    DB-->>A: ❌ value too long for varchar(255)
    A-->>C: 500 Internal Server Error

為什麼 in-app 瀏覽器的 UA 這麼長(又這麼有用)

值得深入一下:為什麼這些 App 的 UA 特別長?因為 in-app「瀏覽器」其實是 App 內嵌的 WebView,App 會在標準 UA 後面故意接上一整段自家的識別欄位。一般 Safari 的 UA 只有約 110~130 字元,而上面那段中括號 [FBAN/…;IABMV/1] 就是 Facebook 附加的 metadata,一口氣十幾個 KEY/VALUE

Token意義
FBANApp 名稱(FBIOS=Facebook iOS;Android 是 FB4A)
FBAV / FBBVApp 版本 / build 版本
FBDV / FBMD裝置型號識別碼(iPhone15,2)/ 機種
FBSN / FBSV作業系統名稱 / 版本
FBSS螢幕縮放倍率(retina scale)
FBLC語系(zh_TW)
IABMVIn-App Browser 版本標記

光這段就佔掉 150~250 字元,加上基底就輕鬆破 300。IG 的 Instagram 300.x (iPhone15,2; iOS 17_5_1; zh_TW; scale=3.00; …)、LINE 的 Line/14.x 結構類似——都是「基底 UA + 自訂 metadata 區段」。

App 為什麼要把這些塞進 UA

UA 是「每個 HTTP request 都自動帶、伺服器端立刻看得到」的最低成本識別管道,所以這些 App 拿它來:

  1. 自我辨識:讓自家網頁(與合作夥伴)一眼認出「這是從 App 內開的」,據此調整行為(例如顯示「用外部瀏覽器開啟」、開關特定功能)。
  2. 分析與歸因:App 版本 / 裝置 / 語系讓數據能歸因流量、針對特定版本除錯、跑 A/B 測試。
  3. 追蹤與廣告量測:in-app browser 會注入腳本,IABMV 等標記配合做轉換追蹤。
  4. 內容適配:網站可依機種、OS、螢幕縮放倍率提供對應的版面與資源。
  5. 安全偵測(反向):有些 OAuth 供應商(如 Google)偵測到 webview 就擋下登入、提示「此瀏覽器不安全」,因為內嵌 webview 可被宿主 App 監看,有釣魚風險。

諷刺的是——正是這段「為了識別而塞的 metadata」把 UA 撐爆 255。這也凸顯:user_agent 這種「長度由第三方決定、你無法控制」的欄位,最不該用 varchar(255) 硬限。


三層修復

修法一:欄位型別放寬成 text

最直接的治本:把 user_agentstring(多數 ORM 預設對應 varchar(255))改成 text(無長度上限)。在 PostgreSQL 中,varchar(255) → text放寬型轉型,不需要重寫整張表,只動系統 catalog、毫秒完成,既有資料完全不受影響,也不會強制任何人重新登入。

修法二:寫入前截斷,防呆

即使欄位已是 text,仍在寫入前對 UA 設一個寬鬆上限(例如 512),擋掉異常或惡意的超長輸入:

const MAX_UA = 512
const safeUserAgent =
  typeof userAgent === 'string' && userAgent.length > MAX_UA
    ? userAgent.slice(0, MAX_UA)
    : userAgent

把這層放在寫入服務內部,所有呼叫端就一次都受到保護,不用每個進入點各自處理。

修法三:次要寫入失敗,不該拖垮主流程

這是最重要的教訓。原本的程式碼把「驗證帳密」和「寫 refresh token」包在同一個 try,任一步失敗都往上拋 500:

// ❌ refresh token 寫入失敗,連已驗證成功的登入一起 500
await validateCredentials(ctx)
const token = await createRefreshToken(...)   // 這裡爆 → 整個登入 500

但 refresh token 只是次要副作用——它失敗時,使用者其實已通過驗證、本該拿到 JWT 成功登入。修正後讓它降級而非中斷:

// ✅ 主流程(登入)成功;次要寫入失敗只記錄,不影響登入結果
await validateCredentials(ctx)         // 失敗才回 4xx
try {
  ctx.body.refreshToken = await createRefreshToken(...)
} catch (e) {
  logger.error('refresh token 產生失敗,但登入仍成功', e)
}

通用教訓

教訓重點
ORM 的 string 預設=varchar(255)存「使用者可控、長度不定」的值(UA、URL、備註)前,先想清楚上限
in-app 瀏覽器的 UA 很長FB / IG / LINE 的 UA 常超過 300 字元,是最容易被忽略的邊界值
次要副作用不該拖垮主動作劃清容錯邊界:登入的本質是驗證身分,旁支寫入失敗應降級而非回 500

結語

這個 bug 的迷惑點在於「帳密正確卻失敗」,但日誌裡那句 value too long for type character varying(255) 一句道破。它提醒我們兩件事:使用者輸入的長度永遠超出想像,而錯誤處理的邊界,要劃在「使用者真正在乎的那件事」上。把欄位放寬、加上截斷、再把容錯邊界劃對,這類「正確操作卻 500」的問題就能徹底根除。