prod 上開始有人登入失敗,網址停在 ?error=invalid_request。

回報陸續進來,但不是每個人都遇得到,同一個人重整幾次又能登入。我自己去試,也中過一次。

「部分使用者 + 間歇性」這個組合,幾乎都是快取在作怪。

而這次的快取設定,是我自己寫的。

這個站的請求路徑是這樣:

使用者 → CDN → Next.js(middleware 先跑,再進 route handler)

CDN 會把回應存一份在自己那邊,下一個人來要同一個東西就直接給他,不用麻煩後面的伺服器。手上有就直接給,這叫 cache hit;沒有才回頭去問後面那台,那次就是 cache miss。

middleware 則是 Next.js 在請求進到頁面或 API 之前會先跑的一段程式。

登入本身走 OAuth 2.0 + PKCE,token 生命週期交給 BFF(Backend for Frontend,這裡就是 Next.js 的 API routes)管。

PKCE 的流程簡化講是這樣:網站在導去授權端之前,先自己產生一組隨機值(code verifier)跟一個 state,存進瀏覽器 cookie;使用者授權完導回來時,callback 要從 cookie 把這兩個值讀回來驗證,才能換到 token。

所以 /api/auth/login 這個 endpoint 的回應裡,會帶三個 Set-Cookie:code verifier、state、還有導回網址。

# 正常的回應長這樣(cookie 名稱以 <> 代稱)
HTTP/1.1 307 Temporary Redirect
Set-Cookie: <code verifier>
Set-Cookie: <state>
Set-Cookie: <導回網址>
Location: https://<授權端>/authorize?...

這三個 cookie 少任何一個,callback 就湊不齊要驗證的值。

原因:一個 matcher 把 auth endpoint 也丟給 CDN

問題出在 middleware。

為了讓內容頁能被 CDN 快取、壓低 TTFB(第一個位元組回來的時間),middleware 對回應統一蓋了 public 的 Cache-Control。而它的 matcher 是這樣寫的:

// middleware.ts
export const config = {
  matcher: '/:path*', // ← 「所有」路徑,包含 /api/auth/login
}
 
export function middleware(req: NextRequest) {
  const res = NextResponse.next()
  res.headers.set('Cache-Control', 'public, s-maxage=300') // ← 連 auth route 都被蓋上 public
  return res
}

/:path* 把每一條路徑都納進來,/api/auth/login 自然也在內。CDN 看到 public,就把這個 endpoint 當成可快取的東西。

CDN 在 cache hit 時會把 Set-Cookie strip 掉。 這是合理的,因為一份被多人共用的快取回應,不該夾帶屬於某個人的 cookie。

# 命中快取之後,CDN 回給下一個人的
HTTP/1.1 307 Temporary Redirect
Location: https://<授權端>/authorize?...
# ← 三個 Set-Cookie 一個都不剩

於是第二個之後打到同一個 CDN 節點的使用者,拿到的是被快取、已經沒有 Set-Cookie 的那份回應。三個 PKCE cookie 一個都沒種進去,callback 時 code verifier 跟 state 全缺,登入直接回 invalid_request。

打到還沒存過東西的節點就沒事,打到存過的就中獎。「有時候好有時候壞」就是這麼來的。

把快取控制權整包交給 CDN、關掉框架自己的多層快取,這件事我在刷了 CDN 還是舊資料:Next.js 的快取沒有清除按鈕寫過,到現在還是覺得是對的。但那套設定有一個例外要先排除掉:會發 Set-Cookie 的路徑。

坑:只在 handler 設 no-store,蓋不掉

第一直覺是去 route handler 裡把它改掉。既然這條不能快取,那就在 /api/auth/login 的回應補一個 no-store:

// app/api/auth/login/route.ts
export async function GET() {
  const res = NextResponse.redirect(authorizeUrl)
  res.headers.set('Cache-Control', 'private, no-store') // ← 想 override middleware
  return res
}

本以為這樣就結了。但實測沒用,auth route 竟然還是被快取。

原因是 middleware 跟 route handler 兩層的 response header 會合併,而 middleware 先跑、已經把 public 蓋上去了;handler 在後面補的這份,並沒有把前面那份整個換掉。

# 兩層合併後,實際送出去的
Cache-Control: public, s-maxage=300
# ← handler 設的 private, no-store 沒出現在這裡

修法:從 matcher 把 /api/* 整段排除

真正的修法是改 matcher,讓 middleware 根本不處理 /api/*:

// middleware.ts
export const config = {
  // (?:/|$) 是為了連 /api 本身也排除掉,只寫 api/ 會漏掉
  matcher: ['/((?!api(?:/|$)).*)'],
}

快取策略因此變成兩邊各管各的:

// 內容頁(middleware):給 CDN 快取
'Cache-Control': 'public, s-maxage=300'
 
// auth route(handler 自己設,middleware 不再碰這條)
'Cache-Control': 'private, no-cache, no-store, must-revalidate'

改完要驗一次,不要只看程式碼。同一條 auth route 連打兩次,看第二次還有沒有 Set-Cookie:

# 連打兩次,兩次都要有 Set-Cookie 才算過
for i in 1 2; do
  curl -sI 'https://<你的網域>/api/auth/login' | grep -iE 'set-cookie|cache-control'
  echo '---'
done

auth endpoint 從此不進 CDN,Set-Cookie 每次都完整種到使用者瀏覽器,登入恢復正常。

regex 本身不用記,記那個判斷就好:擋快取要擋在 middleware 的 matcher,那是請求進來的第一關。等它跑完再去 handler 設 header,前面那份已經蓋上去了。

附帶:同一條登入流程上的 open redirect

修這個 cache bug 時,順手也把另一個 auth 的洞補了,剛好都跟「不要信任不可信的輸入」有關。

登入完要導回使用者原本的頁面,舊的做法是讀 x-forwarded-host 來組這個導回網址:

// ❌ 信任 x-forwarded-host 組 URL
const base = `https://${req.headers.get('x-forwarded-host')}`
const redirectUrl = `${base}${backUrl}`

x-forwarded-host 是 client 可以偽造的 header。攻擊者塞一個自己的網域進來,就能讓登入流程把人導去釣魚站,甚至把 token 帶過去。

修法是不再信任任何 forwarded header,URL 一律用自己設定的 appUrl 組;導回目標再過一層白名單驗證,不合法就退回首頁:

// ✅ 只信任自己的 appUrl,導回目標還要過白名單
const base = config.appUrl
const redirectUrl = sanitizeRedirectUrl(backUrl, {
  allowedOrigins: [config.appUrl, ...allowedRedirectOrigins],
}) // ← 不在白名單就回首頁,不照使用者給的網址跳

登出的 callback 也一樣:state 對不上就導回首頁,使用者指定的網址一律不照跳,免得登出流程被當成開放導轉的跳板。

修完之後才想到的事

還好這次踩到的是登入。登入壞掉會有人立刻回報,invalid_request 也夠具體,一路查回 middleware 沒花太久。

真正讓我不太舒服的是另一件事:那個 matcher 排除的是 /api/*,它認得的只有一段路徑前綴。至於「這個回應是不是只屬於某一個人」,它根本沒在看。哪天有人在 /api 以外的地方寫一條會發 Set-Cookie 的 route,middleware 一樣會蓋上 public,一樣沒有東西會攔它。

我目前沒有自動的辦法擋這個,只能靠 code review 的時候有人想起來。前傳那篇的結論是快取要能手動救。這篇再補一個:快取的範圍請用列舉的,一條一條寫出哪些路徑可以快取。

延伸閱讀

參考資料