前言:帳密明明正確,卻回 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 | 意義 |
|---|---|
FBAN | App 名稱(FBIOS=Facebook iOS;Android 是 FB4A) |
FBAV / FBBV | App 版本 / build 版本 |
FBDV / FBMD | 裝置型號識別碼(iPhone15,2)/ 機種 |
FBSN / FBSV | 作業系統名稱 / 版本 |
FBSS | 螢幕縮放倍率(retina scale) |
FBLC | 語系(zh_TW) |
IABMV | In-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 拿它來:
- 自我辨識:讓自家網頁(與合作夥伴)一眼認出「這是從 App 內開的」,據此調整行為(例如顯示「用外部瀏覽器開啟」、開關特定功能)。
- 分析與歸因:App 版本 / 裝置 / 語系讓數據能歸因流量、針對特定版本除錯、跑 A/B 測試。
- 追蹤與廣告量測:in-app browser 會注入腳本,
IABMV等標記配合做轉換追蹤。 - 內容適配:網站可依機種、OS、螢幕縮放倍率提供對應的版面與資源。
- 安全偵測(反向):有些 OAuth 供應商(如 Google)偵測到 webview 就擋下登入、提示「此瀏覽器不安全」,因為內嵌 webview 可被宿主 App 監看,有釣魚風險。
諷刺的是——正是這段「為了識別而塞的 metadata」把 UA 撐爆 255。這也凸顯:user_agent 這種「長度由第三方決定、你無法控制」的欄位,最不該用 varchar(255) 硬限。
三層修復
修法一:欄位型別放寬成 text
最直接的治本:把 user_agent 從 string(多數 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」的問題就能徹底根除。
