- Published on
登入有時候好、有時候壞:CDN 把 Set-Cookie 吃掉了
- Authors
-
-
- Name
- Alex Yu
-
prod 上開始有人登入失敗,網址停在 ?error=invalid_request。
回報陸續進來,但不是每個人都遇得到,同一個人重整幾次又能登入。我自己去試,也中過一次。
「部分使用者 + 間歇性」這個組合,幾乎都是快取在作怪。
而這次的快取設定,是我自己寫的。
背景:登入要靠三個 cookie 才走得完
這個站的請求路徑是這樣:
使用者 → 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 '---'
doneauth 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 的時候有人想起來。前傳那篇的結論是快取要能手動救。這篇再補一個:快取的範圍請用列舉的,一條一條寫出哪些路徑可以快取。
延伸閱讀
- 刷了 CDN 還是舊資料:Next.js 的快取沒有清除按鈕:本篇的前傳,那套「只留 CDN 一層」的策略怎麼決定的
- 6,401 個 IP、3 分鐘、7,262 個錯誤:一隻普通的爬蟲怎麼打掛我的 search 頁:快取的另一面,請求全部打回 origin 會發生什麼事