# RFC 10017 之後，BFF 的第一個理由是 token 不進瀏覽器

> RFC 10017 把 BFF 列為瀏覽器應用最安全的 OAuth 架構，token 由伺服器保管。設計時先決定 token 放哪，再談聚合，文中以 Go 標準庫示範最小實作。

原文：https://sheng.page/posts/bff-as-token-boundary/ · 發布：2026-10-18 · 作者：Sheng

IETF 的 OAuth 工作小組花了好幾年寫的 browser-based apps 指引，在 2026 年 8 月正式成為 RFC 10017，和給原生 App 的 RFC 8252 一起列在 BCP 212 底下。文件第 6 節把三種架構依安全程度由高到低排列，BFF 排第一。理由很直接：token 由 BFF 管理，不交給瀏覽器裡的程式，就算攻擊者完全控制了前端，也沒有 token 可偷。

設計 BFF 時，我會先決定 token 放在哪裡。這個系列前幾篇談的大多是聚合與裁剪，那些理由都還成立，但可以晚點談。瀏覽器應該只持有隨機的 session ID，不含使用者資料與權限資訊；它仍是登入憑證，本身卻不帶任何權限。之前改過的一個後台就遇過這個取捨，後面的 Go 片段會示範怎麼把 token 留在 BFF。

## 一、從一個能偽造的 role cookie 講起

之前做過一個後台 MVP，第一版的 admin 頁面把使用者的 role 用純文字寫在 cookie 裡，SSR 的 admin layout 直接讀這個值決定能不能進管理頁。使用者打開開發者工具改一下 cookie，就能進到管理頁，這是授權問題，畫面只是表象。prototype 常這樣寫，因為最快，但權限資料放在使用者手上，必須改由伺服器查核。

後來改成伺服器端 session。做法有很多種，例如登入時用 Argon2id 驗密碼，通過後發一個 opaque token，明文用 base64url 編碼放進瀏覽器的 cookie，資料庫只存它的 SHA-256 雜湊；admin 的 session TTL 可以設得比一般會員短，例如 8 小時；用 SvelteKit 的話，`hooks.server` 依 cookie 呼叫 `/me` endpoint，解析出 `locals.user` 與 `locals.admin`，前端的 `fetch` 一律帶 `credentials: include`。

這樣做之後，權限資料留在伺服器，瀏覽器只保存 session ID。原本 cookie 裡放的是權限本身，現在只是一個隨機字串，要查表才知道它代表誰。資料庫外洩時，攻擊者拿到的雜湊也沒辦法直接拿來登入。session 在伺服器端還有一個好處，停權會員時，那個後台會撤銷該使用者所有有效的 session，下一個請求就進不來了。

RFC 10017 第 7.1 節剛好談到這種情況：前端和 API 在同一個網域、沒有第三方 API 要呼叫時，用 OAuth 取代 session 管理常常只是把簡單的應用弄複雜，直接用伺服器端的 cookie session 就好，cookie 設定沿用第 6.1.3.2 節給 BFF 的規則。

這個後台的改版和 BFF 用的是同一個想法，差別只在 token 的來源。自己發的 session 用不到 OAuth；使用外部授權伺服器時，若希望前端程式接觸不到 OAuth token，就由 BFF 代為保管。

## 二、RFC 10017 的三種架構

RFC 10017 第 6 節的三種架構，差別在 token 落在哪裡：

| 架構 | OAuth client 的角色 | 瀏覽器手上有什麼 |
|---|---|---|
| Backend for Frontend | BFF 是 confidential client | 只有 session cookie |
| Token-mediating backend | 後端是 confidential client | access token |
| Browser-based OAuth client | 前端自己是 public client | access token，可能還有 refresh token |

BFF 的 session cookie 有兩種做法，RFC 第 6.1.2.3 節都有談：伺服器端 session 只把 session ID 交給瀏覽器，資料留在伺服器；client 端 session 把資料放進簽章過的 cookie，第 6.1.3.2 節要求裡面有 access token 時 SHOULD 加密。兩種做法的 cookie 都是 `HttpOnly`，前端程式讀不到，本文採用前一種。

Token-mediating backend 由後端換 token，再把 access token 交給前端，讓前端直接打 resource server。後端不用轉送所有請求，比 BFF 輕，但 RFC 認為它的安全性不如 BFF，建議選它之前先評估能不能用完整的 BFF。第三種完全在瀏覽器裡完成 OAuth 流程，沒有 client 憑證，RFC 指出攻擊者即使沒偷到既有的 token，仍然能自己向授權伺服器要一組新的。

這個排序依據的是第 5 節的威脅模型：假設攻擊者已經能在應用程式裡執行 JavaScript（XSS、被汙染的第三方套件都算），再看他能做什麼。只要前端能拿到 token，就算把儲存的既有 token 完全隔離，攻擊者還是能用隱藏的 iframe 在背景跑一次授權流程，要一組新的 token。前提是使用者在授權伺服器的登入狀態還有效，授權伺服器也支援不經使用者互動的 silent flow。

偷到 refresh token 的話，攻擊者只要讓應用程式不再用到那一組（例如等使用者關掉分頁），就能在自己的機器上持續換新的 access token，也不會觸發 reuse detection，直到 refresh token 過期、被輪替或被撤銷為止。RFC 提到，瀏覽器應用的 refresh token 常常有效好幾個小時甚至好幾天。我在另一個多租戶的 prototype 走的是另一條路：access token 效期 15 分鐘，放在前端記憶體；refresh token 效期 7 天，放在 `__Host-` 前綴、`HttpOnly`、`Secure`、`SameSite=Lax` 的 cookie，並做 rotation 與 reuse detection，偵測到重複使用就讓同一個 family 的 token 整組失效。access token 會進到瀏覽器，歸類上比較接近 token-mediating backend。當時接受這個取捨，是因為 refresh token 前端程式讀不到，偷不走，access token 被偷走也只有 15 分鐘可用。照 RFC 的分析，這樣仍有缺口：第 6.2.4.3.1 節指出惡意程式可以假扮正常的應用程式，向後端要最新的 access token，cookie 讓瀏覽器自動帶上，15 分鐘的限制只是讓攻擊者要多要幾次。所以第 6.2.4.4 節強烈建議先評估能不能改用完整的 BFF，現在要再做這類系統，我會先照這個建議評估。

在這三種架構中，BFF 讓前端程式接觸不到 OAuth token，第 6.1.4.3 節因此強烈建議商業應用、敏感應用與處理個人資料的應用採用 BFF。

〈[Framework 的 Server 層不等於 BFF](/posts/framework-server-boundaries-vs-bff/)〉用「誰面向瀏覽器、誰面向後端、誰負責風險」來切邊界，RFC 10017 等於替「誰負責風險」補上一個具體答案：持有 token 的那一層。

## 三、BFF 先保管 token

〈[BFF 在 2025 還沒過時，它本來就不是潮流](/posts/bff-still-worth-it-in-2025/)〉和〈[BFF 的兩種組合](/posts/bff-call-chains-with-htmx-and-sveltekit/)〉講 BFF 做三件事：聚合、裁剪、藏。當時「藏」指的是金鑰與下游位址，RFC 10017 把使用者的 access token 與 refresh token 也放進這個範圍，而且排在最前面。

RFC 第 6.1 節列出 BFF 的三項職責：以 confidential client 的身分向授權伺服器取得 token，把 token 綁到一個 cookie session，前端打 API 時再帶上 access token 轉給 resource server。這裡沒有聚合，光是保管 token 與轉送請求，就足以成為 BFF 存在的理由。

我排取捨的順序是 Security、Simplicity、Correctness、Performance。token 放哪裡屬於第一項，要先決定，聚合和裁剪屬於後面幾項，可以晚點談。先界定誰保管 token，也比較容易限制 BFF 的職責，維持〈[用 Fastify 寫一層薄 BFF](/posts/thin-bff-with-fastify/)〉那種只做轉譯的範圍。

持有 token 之後，BFF 多了 session 儲存與 token 更新兩件事。把 session 放進 Valkey 這類共用儲存，BFF 本身仍然可以用多個無狀態副本執行。

## 四、用 Go 標準庫寫一個最小 BFF

下面用 Go 標準庫示範 BFF，以 `net/http` 處理 HTTP 請求，授權伺服器（IdP）和下游 API 都用 `httptest` 模擬，不需要真的 OAuth 伺服器。範例做四件事：登入 callback 以 client secret 向 IdP 換 token，發一個 `__Host-` 前綴的 session cookie；每個 `/api/` 請求都要帶固定的自訂 header，沒帶就回 403；用 session 找出 access token，轉給下游 API；session 的 key 存 SHA-256 雜湊，和前面例子的做法一樣。client secret 和 session ID 都用 `crypto/rand` 產生。

以下程式碼是節錄，登入 callback 的其餘部分、假 IdP、假 API、型別定義與 `main` 沒有貼出來，輸出則是完整程式跑出來的。

設定 cookie 的部分：

```go
const (
	cookieName = "__Host-sid"
	csrfHeader = "X-BFF-Request"
	sessionTTL = 8 * time.Hour
)

sid := randToken(32) // crypto/rand 產生 32 bytes，base64url 編碼
b.mu.Lock()
b.sessions[sha256.Sum256([]byte(sid))] = session{accessToken: tok, expires: time.Now().Add(sessionTTL)}
b.mu.Unlock()

http.SetCookie(w, &http.Cookie{
	Name:     cookieName,
	Value:    sid,
	Path:     "/",
	MaxAge:   int(sessionTTL.Seconds()),
	HttpOnly: true,
	Secure:   true,
	SameSite: http.SameSiteStrictMode,
	// 不設 Domain：__Host- 前綴要求不能有 Domain
})
```

接著檢查自訂 header，再轉送請求。`b.http` 這個 client 設了 `CheckRedirect` 回傳 `http.ErrUseLastResponse`，不跟隨下游的 redirect（Go 的 `http.Client` 預設會跟隨），下游回 3xx 時 BFF 直接回 502：

```go
func (b *BFF) requireHeader(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get(csrfHeader) != "1" {
			http.Error(w, "missing "+csrfHeader, http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func (b *BFF) proxy(w http.ResponseWriter, r *http.Request) {
	s, ok := b.lookup(r) // 以 cookie 的 SHA-256 查 session，過期就刪掉
	if !ok {
		http.Error(w, "no session", http.StatusUnauthorized)
		return
	}
	u, err := url.Parse(b.apiURL) // 主機固定，前端只能決定 path 與 query
	if err != nil {
		http.Error(w, "bad upstream", http.StatusInternalServerError)
		return
	}
	u.Path = strings.TrimPrefix(r.URL.Path, "/api")
	u.RawQuery = r.URL.RawQuery
	req, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), http.MaxBytesReader(w, r.Body, maxBodySize))
	if err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	req.Header.Set("Authorization", "Bearer "+s.accessToken)
	if ct := r.Header.Get("Content-Type"); ct != "" {
		req.Header.Set("Content-Type", ct)
	}
	resp, err := b.http.Do(req) // b.http 不跟隨 redirect，見 main
	if err != nil {
		log.Printf("call api: %v", fmt.Errorf("proxy %s: %w", u.Path, err))
		http.Error(w, "upstream error", http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 && resp.StatusCode < 400 {
		http.Error(w, "upstream redirect not allowed", http.StatusBadGateway)
		return
	}
	// 多讀 1 byte 判斷是否超過上限，超過就回 502，不送出截斷的內容
	body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize+1))
	if err != nil {
		log.Printf("read upstream body: %v", fmt.Errorf("proxy %s: %w", u.Path, err))
		http.Error(w, "upstream error", http.StatusBadGateway)
		return
	}
	if len(body) > maxBodySize {
		http.Error(w, "upstream response too large", http.StatusBadGateway)
		return
	}
	w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
	w.WriteHeader(resp.StatusCode)
	if _, err := w.Write(body); err != nil {
		log.Printf("write response: %v", err)
	}
}
```

`main` 依序模擬七個請求：登入 callback、只帶 cookie、cookie 加 header（帶 query string）、只帶 header、偽造的 cookie、下游回 redirect、下游回應超過 1 MiB 上限。假 API 只接受 IdP 發出的那組 bearer token，請求裡帶了 cookie 就拒絕，用來確認 BFF 沒有把瀏覽器的 cookie 往下游送。用 `go run -race .` 執行（session ID 每次不同，輸出只留前 6 個字元）：

```text
1. callback: 303 See Other
   Set-Cookie: __Host-sid=fiY4pP…(43 chars); Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Strict
   token in Set-Cookie: false
2. cookie, no header: 403 Forbidden missing X-BFF-Request
3. cookie + header  : 200 OK {"user":"alice","role":"admin","query":"lang=zh"}
   token in response: false
4. header, no cookie: 401 Unauthorized no session
5. forged cookie    : 401 Unauthorized no session
6. upstream redirect: 502 Bad Gateway upstream redirect not allowed
7. oversized body   : 502 Bad Gateway upstream response too large
```

整個過程中，access token 都沒有出現在 `Set-Cookie` 或回應內容，瀏覽器拿到的只有 43 個字元的 session ID。只帶 cookie 的請求在檢查 header 時就回 403，根本不會去查 session。

`io.LimitReader` 到上限只會回 EOF，直接用 `io.Copy` 會在 200 的狀態碼下送出截斷的內容，所以範例多讀 1 byte 判斷有沒有超過，超過就回 502。整份回應會先讀進記憶體，streaming API 得換個做法。

範例刻意省略了兩件事。authorization code flow 的 CSRF 防護沒有做：RFC 第 6.1.1 節描述 BFF 換 token 時要帶 PKCE 的 code verifier，RFC 9700 第 2.1 節允許 client 在確認授權伺服器支援 PKCE 之後，依賴 PKCE 提供的 CSRF 防護，否則必須用一次性的 `state`。token 過期時用 refresh token 換新的也沒做，RFC 第 6.1.2.2 節說 refresh token 同樣由 BFF 保管、和 session 綁在一起。

轉送時主機固定是 `b.apiURL`，前端只能決定 path 與 query，加上不跟隨 redirect，請求不會被帶到別的主機。第 6.1.3.6 節的要求是 MUST：BFF 轉送前必須驗證目標主機，只允許明確列出的主機與路徑。實作時要維護一份允許的 resource server 清單，別讓 BFF 變成 open proxy。範例沒有限制路徑，實際部署還要加上路徑與 HTTP method 的限制。

## 五、cookie 屬性與 CSRF 的細節

RFC 第 6.1.3.2 節對 cookie 的要求分成兩級：`Secure` 與 `HttpOnly` 是 MUST，`SameSite=Strict`、`Path=/`、不設 `Domain` 是 SHOULD。名稱前綴建議用 `__Host-Http-`，定義在 HTTP 工作小組的 `draft-ietf-httpbis-layered-cookies`，目前還是 Internet-Draft。

範例用的是較早就有的 `__Host-` 前綴，瀏覽器會拒絕不帶 `Secure`、`Path` 不是 `/` 或帶了 `Domain` 的 `__Host-` cookie。`__Host-Http-` 另外要求 `HttpOnly`，確保 cookie 只能由 `Set-Cookie` header 設定，前端的 JavaScript 沒辦法自己寫一個進去。不過 MDN 也提醒，不支援前綴的瀏覽器會照常接受這些 cookie，這些保證在那裡都不成立。

用了 `SameSite=Strict`，使用者從其他網站點連結進來時，瀏覽器就不會帶這個 cookie。對單頁應用影響不大，頁面載入之後才用 `fetch` 打 BFF，那時已經是 same-site 請求；如果首頁要在伺服器端依 session 渲染，像前面例子用 `hooks.server` 解析 `locals` 那樣，從信件或聊天訊息點進來的使用者，第一頁會被當成沒登入，要不要退回 `Lax` 得自己判斷。那個後台在 MVP 階段判斷 `SameSite=Lax` 已經夠用，CSRF 防護列為之後再評估；照本文後面整理的 RFC 要求，正式上線前這一項還是要補上。

RFC 第 6.1.3.3.1 節也提醒，`SameSite` 只在 BFF 不和其他應用共用同一個 site 時才夠用。同一個註冊網域下掛著其他服務，那些服務就可能發出被視為 same-site 的請求。

RFC 第 6.1.3.3.2 節建議再加一道自訂 header，因為 CORS 的 safelisted request，例如沒有 body 的 POST、一般的表單送出，即使是 cross-origin，瀏覽器也不做 preflight 就直接送出。CORS 只能擋住 JavaScript 讀回應，請求本身已經到了伺服器。

帶自訂 header 的 cross-origin 請求一定會先 preflight，BFF 沒有在 preflight 回應中允許這類請求，瀏覽器就不會送出。RFC 也要求採用這個機制時，每個進來的請求都必須帶這個 header。這道防線依賴 BFF 的 CORS 設定，範例的 BFF 完全不回 CORS header，所以只有 same-origin 的前端能帶這個 header 打進來。

前端和 BFF 是 cross-origin 但仍是 same-site 時（例如 `app.example.com` 與 `bff.example.com`），`SameSite=Strict` 的 cookie 仍然會送出。前端得用 `credentials: include`，BFF 也得回 `Access-Control-Allow-Credentials` 並指定明確的來源，不能用 `*`。這份允許清單也是 CSRF 防護的一部分，每個列入的來源都必須可信任。

cross-site 部署就不同了，Strict cookie 不會隨 cross-site 請求送出，瀏覽器的第三方 cookie 政策也照樣適用，`credentials` 與 CORS 的設定都繞不過去，BFF 最好和前端放在同一個 site。第 6.1.3.3.3 節也提到框架內建的 anti-forgery cookie（double-submit）做法，〈[安全中介層全家桶](/posts/gortex-security-middleware-suite/)〉談過 gortex 的 double-submit 實作，兩種可以並用。

## 六、BFF 擋不住的部分

第 6.1.4.2 節說明，BFF 能化解偷走 token 的幾種攻擊，第 5.1.4 節的「透過使用者的瀏覽器代理請求」仍然成立。惡意 JavaScript 不需要 token，直接在使用者的頁面裡呼叫 BFF，瀏覽器會自動帶上 cookie，BFF 分不出這是正常操作還是攻擊。

這種攻擊依賴使用者瀏覽器裡的惡意程式與有效的 session，攻擊者沒辦法把它帶到自己的機器上。關掉分頁不代表漏洞消失，再次載入受汙染的頁面仍會被攻擊。偷走的 refresh token 則只要還有效、未被撤銷，就可以在攻擊者自己的機器上繼續使用。

XSS 還是得另外處理，CSP、相依套件的管理都省不掉。RFC 第 6.1.4.1 節也提到，所有請求都會經過 BFF，適合在這裡做異常偵測與 rate limit。管理後台這類高權限頁面，把 session TTL 設短一點能縮小這段時間窗，前面例子把 admin session 設為 8 小時，就是這個考量。

另外兩份文件可以搭配著看：原生 App 不適用這套 cookie 機制，要看同屬 BCP 212 的 RFC 8252；OAuth 本身的安全要求整理在 RFC 9700（BCP 240，2025 年 1 月發布），RFC 10017 是在它之上專門處理瀏覽器的情境。

## 補充筆記

- RFC 10017（BCP 212，2026 年 8 月）把瀏覽器應用的 OAuth 架構依安全程度排序：BFF、token-mediating backend、browser-based OAuth client，三者之中只有 BFF 讓前端程式接觸不到 OAuth token。
- BFF 以 confidential client 身分保管 access token 與 refresh token，瀏覽器只拿到 `HttpOnly` 的 session cookie。本文用伺服器端 session，cookie 只放 session ID；設計時先決定 token 放哪，聚合可以晚點談。
- 同網域、沒有第三方 API 的應用，RFC 第 7.1 節認為伺服器端 cookie session 就夠，後台把純文字 role cookie 改成伺服器端 session，也是這個想法。
- cookie 要 `Secure`、`HttpOnly`，建議 `SameSite=Strict`、`Path=/`、不設 `Domain`；CSRF 再加一道每個請求都要帶的自訂 header，前提是 CORS 不放行其他來源。
- 範例的轉送固定主機、保留 query string、不跟隨 redirect、回應超過上限回 502；第 6.1.3.6 節要求只轉送到明確允許的主機與路徑（MUST），範例還缺路徑限制。
- BFF 擋不住惡意 JavaScript 透過使用者的瀏覽器發請求，XSS 防護、rate limit 與較短的 session TTL 仍然需要。

## 延伸閱讀

- RFC 10017〈[OAuth 2.0 for Browser-Based Applications](https://www.rfc-editor.org/rfc/rfc10017.html)〉：本文引用的三種架構、cookie 規則、CSRF 防護與威脅分析都出自這份文件。
- IETF Datatracker〈[draft-ietf-oauth-browser-based-apps](https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/)〉：這份 RFC 的草稿歷史，最後一版是 -27。
- RFC Editor〈[BCP 212](https://www.rfc-editor.org/info/bcp212)〉：BCP 212 由 RFC 8252（原生 App）與 RFC 10017（瀏覽器應用）組成。
- RFC 9700〈[Best Current Practice for OAuth 2.0 Security](https://www.rfc-editor.org/info/rfc9700)〉：OAuth 2.0 的整體安全建議，BCP 240。
- RFC 8252〈[OAuth 2.0 for Native Apps](https://www.rfc-editor.org/info/rfc8252)〉：原生 App 的 OAuth 做法。
- IETF Datatracker〈[draft-ietf-httpbis-layered-cookies](https://datatracker.ietf.org/doc/draft-ietf-httpbis-layered-cookies/)〉：定義 `__Http-` 與 `__Host-Http-` 前綴的草稿。
- MDN〈[Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie)〉：cookie 前綴與 `SameSite` 各個值的行為。
- MDN〈[Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS)〉：帶 credentials 的 CORS 請求不能用 `*`，第三方 cookie 政策照樣適用。

> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 範例在 Go 1.24.7 實測。