昨天 Google 已經把使用者送回 BuJo,Backend 也成功驗證了 Google ID Token。
照我以前的想法:
Google 都已經知道我是誰了,那不就登入完成了嗎?
結果還沒有。
因為 Google 認得你,跟 BuJo 接下來每一個 Request 都認得你,是兩件不同的事。
這也是我以前最容易全部混成「Token」的一層。
前兩天我們已經看過 Google ID Token 和 BuJo JWT 的角色不同。
Google ID Token 負責讓 Backend 驗證 Google 身分。
但驗證成功之後,BuJo 還需要建立自己的登入狀態:
Google 說:
這個人是 Google User A
↓
BuJo 找到:
A 對應到 BuJo User 123
↓
BuJo 接下來要記得:
這個瀏覽器現在是 User 123
所以 BuJo 會再發一顆自己的 JWT。
今天真正要看的不是 JWT 的格式,而是:
BuJo 發完這顆 JWT 以後,到底怎麼把它交給瀏覽器,又放在哪裡?
早期 BuJo 的流程是:
Backend
↓
簽 BuJo JWT
↓
JWT 放進 HTTP Response Body
↓
Frontend 收到
↓
存進 localStorage
以前我會把 Response Body、localStorage、Cookie 都理解成:
「把 Token 放進去的某個地方。」
但它們其實不是同一層。
我後來覺得最好懂的方式,是把一個 HTTP Response 想成:
HTTP Response
= 外送包裹
Response Headers
= 包裹外面的配送標籤
Response Body
= 包裹裡面裝的東西
JWT / user data
= 裡面真正的甜點
這樣一來,早期 BuJo 的做法就比較有畫面了。
Backend 回了一個包裹,例如:
{
"token": "bujo-jwt...",
"user": {
"id": "123",
"name": "Chuni"
}
}
JWT 放在包裹裡。
Frontend 收到後,自己把 JWT 拿出來,再存進 localStorage。
所以可以直接拆成兩層:
Response Body / Response Header
→ 資料這次怎麼從 Backend 傳回來
localStorage / Cookie
→ 瀏覽器收到之後把資料放在哪裡
後來 BuJo 把 JWT 的處理改成:
Backend
↓
簽 BuJo JWT
↓
Set-Cookie Response Header
↓
瀏覽器收到
↓
自動保存成 Cookie
這次 JWT 不再放進 Response Body 讓 Frontend JavaScript 自己拿。
而是在 Response Header 裡,由 Set-Cookie 告訴瀏覽器:
「請幫我把這顆東西存成 Cookie。」
套回外送包裹:
以前:
甜點放在包裹裡
→ Frontend 拆開
→ 自己拿去收
後來:
包裹外面的配送標籤直接寫:
「這一份請瀏覽器幫忙放進 Cookie」
我當時會一路回頭查 Google Login,就是因為一直在想:
「Token 放在 localStorage 真的安全嗎?」
localStorage 有一個很直接的特性:
JavaScript 可以讀。
例如:
localStorage.getItem('token')
這本來也是它方便的地方。
Frontend 要拿 Token 時很好拿。
但如果網站出現 XSS,也就是跨站腳本攻擊(Cross-Site Scripting),攻擊者成功讓惡意 JavaScript 在你的網站裡執行,這個「很好拿」就會反過來變成問題。
localStorage
↓
JWT
↓
惡意 JavaScript 讀走 Token
這也是為什麼後來 BuJo 改成 HttpOnly Cookie。
HttpOnly 是 Cookie 的一個屬性(Attribute)。
設了 HttpOnly 之後,JavaScript 就不能透過 document.cookie 直接把那顆 Cookie 讀出來。
所以如果 JWT 放在 HttpOnly Cookie 裡:
XSS 發生
↓
惡意 JavaScript
↓
不能直接讀出這顆 JWT
這確實降低了 Token 被 JavaScript 直接偷走的風險。
但:
HttpOnly 不是「解決 XSS」。
如果惡意 JavaScript 已經成功在你的頁面裡執行,它還是可能利用使用者目前的登入狀態做其他事情。
HttpOnly 主要保護的是:
不要讓 JavaScript 直接把 Cookie 裡的 Token 內容讀走。
我知道 Cookie 也可能碰到 CSRF 時,第一個反應其實是:
「那不就是兩個都不安全嗎?」
後來才發現,真正要看的不是哪個方案「完全沒有風險」,而是:
換了一種儲存與傳遞方式之後,風險模型變成什麼?
localStorage 裡的 Token 可以被 JavaScript 讀,因此特別需要注意 XSS 造成的 Token 竊取。
Cookie 則會在符合規則時由瀏覽器自動帶上請求,因此還需要處理另一種風險:
跨站請求偽造(Cross-Site Request Forgery,CSRF)。
簡單說,就是攻擊者想辦法讓已經登入的瀏覽器,在使用者不知情的狀況下,對目標網站送出一個帶著登入 Cookie 的請求。
所以換成 Cookie,不代表:
「安全問題消失。」
而是:
我們降低了一種風險,接著要處理 Cookie 自己的安全邊界。
把 JWT 存進 Cookie 之後,事情不是只有:
「Token 現在放在 Cookie 裡。」
Cookie 本身還帶著一組屬性(Attributes),分別控制不同事情:
Cookie
├─ HttpOnly
│ └─ JavaScript 能不能直接讀
│
├─ Secure
│ └─ 是否只透過 HTTPS 傳送
│
├─ SameSite
│ └─ 跨 Site 情境下是否帶上
│
├─ Domain
│ └─ 哪些 host(主機名稱)適用
│
└─ Path
└─ 哪些 URL 路徑適用
這五個不是五種 Cookie,而是同一顆 Cookie 身上的不同規則。
HttpOnly不讓 JavaScript 直接讀取這顆 Cookie。
Secure限制 Cookie 只透過 HTTPS 傳送。
BuJo 正式環境會開啟 Secure。
SameSite控制 Cookie 在跨 Site 的情境下,要不要被瀏覽器一起帶上。
常見值有:
Strict → 只在 Same-Site 情境下帶 Cookie
Lax → 部分跨 Site 情境仍可以帶 Cookie
None → 允許 Cookie 用在跨 Site 情境,但必須搭配 Secure
BuJo 在本機開發環境使用 SameSite=Strict;production 環境則使用:
SameSite=None → 我允許跨 Site 帶 Cookie
Secure=true → 但只能透過 HTTPS 帶
這裡先記得 SameSite 跟「是不是跨站」有關就好,Site 到底怎麼判斷,後面會再拆。
Domain控制這顆 Cookie 可以被哪些 host(主機名稱)收到。
例如 Cookie 綁在哪一個 Domain,會影響子網域是否也落在它的適用範圍。
Path控制同一個 host 底下,哪些網址路徑會帶上這顆 Cookie。
BuJo 的 Auth Cookie 目前使用:
Path=/
也就是整個網站路徑都在它的範圍裡。
BuJo 本來就是前後端分離。
當登入狀態改成由 Cookie 維持之後,一次 Frontend → Backend 的登入請求,會同時牽涉三邊:
Frontend
└─ 這次請求要不要帶登入 Cookie?
瀏覽器
├─ Frontend 和 Backend 是不是同一個 Origin?
└─ Cookie 的規則允不允許一起帶上?
Backend
├─ CORS 有沒有允許這個 Origin?
└─ 有沒有允許帶 Credentials?
這些不是寫在同一包 Request 裡的三個欄位。
而是:
同一次請求經過 Frontend、瀏覽器和 Backend 時,分別會碰到的規則。
接下來再一層一層拆。
第一個重要概念叫:
來源(Origin)。
Origin 由三個東西組成:
scheme(通訊協定) + host(主機名稱) + port(連接埠)
例如:
http://localhost:5173
和:
http://localhost:3000
它們的:
scheme
→ 都是 http
host
→ 都是 localhost
port
→ 一個是 5173
一個是 3000
Port 不一樣,所以它們就是不同 Origin。
人類看:
「都 localhost 啊,不都我電腦嗎?」
但瀏覽器判斷的是 URL 裡的這三個部分。
scheme、host、port 三個都一樣,才是同源(Same-Origin)。
只要其中一個不同,就是跨來源(Cross-Origin)。
這件事第一次搞懂時,我才知道以前我把「同一台電腦」、「同一個網站」、「同一個 Origin」全部混在一起了。
瀏覽器本身有一套重要安全規則:
同源政策(Same-Origin Policy)。
可以先理解成:
不同 Origin 的網頁,不能隨便讀彼此的資料。
不然任意網站只要打開,就可以亂讀你其他網站上的資料。
但前後端分離很常會出現:
Frontend
http://localhost:5173
Backend
http://localhost:3000
明明是同一個產品,對瀏覽器來說卻是不同 Origin。
那 Frontend 還是需要呼叫 Backend 啊。
這時才會碰到下一個概念:
CORS。
CORS,全名是:
跨來源資源共享(Cross-Origin Resource Sharing)。
我以前對 CORS 的理解很像:
「不同網址不能互傳。」
但這其實太模糊了。
更準確地說,CORS 是 Backend 對瀏覽器表達:
「雖然這個 Request 是從另一個 Origin 來的,但這個 Origin 是我允許的,你可以讓它讀我的 Response。」
如果要用比較直覺的方式理解,可以先把它想成一張「白名單」:
Cross-Origin(跨來源)
→ Frontend 和 Backend 的 Origin 不一樣
CORS(跨來源資源共享)
→ Backend 告訴瀏覽器:
哪些 Origin 在白名單裡
BuJo Backend 現在就是維護一份允許的 Origin 白名單。
所以這一條可以串成:
瀏覽器看到 URL
↓
判斷 Origin
↓
Frontend / Backend 不同 Origin
↓
受到同源政策限制
↓
Frontend 想讀 Backend 的 Response
↓
瀏覽器看 Backend 有沒有把這個 Origin 放進白名單
這裡還有一組很容易混在一起的概念。
前面已經知道:
Origin
= scheme(通訊協定) + host(主機名稱) + port(連接埠)
三個都一樣,才是同源。
但 Cookie 的 SameSite 看的是另一套判斷:
Site(站點)。
所以:
Cross-Origin 不代表一定就是 Cross-Site。
拿 BuJo 自己的部署網址來看會最清楚。
Frontend
https://bujo.live
Backend
https://api.bujo.live
兩邊的 host 不同:
bujo.live
api.bujo.live
所以它們是:
Cross-Origin(跨來源)
但它們都屬於 bujo.live 這個 Site,而且都是 HTTPS,所以仍然是:
Same-Site(同站)
BuJo 的 dev 環境則是:
Frontend
https://bu-jo-inky.vercel.app
Backend
https://bujobackend-gnfd.onrender.com
這次不只 host 不一樣,兩邊也分別屬於不同的 Site:
vercel.app
onrender.com
所以是:
Cross-Origin(跨來源)
+
Cross-Site(跨站)
也就是說:
Origin 和 Site 是兩套不同的判斷。Cross-Origin 不一定代表 Cross-Site,而 Cookie 的
SameSite看的是 Site。
所以像昨天說的,之前 Google 登入時,Safari 曾經出現不能正常登入的 Bug,也可能和跨 Origin、跨 Site 情境下的 Cookie 規則,或其他瀏覽器端政策有關。
前面解決的是:
「Frontend 和 Backend 不同 Origin 時,Frontend 能不能讀 Backend 的 Response?」
但登入還多了一件事:
「這次 Request 能不能把登入 Cookie 一起帶過去?」
這時會同時牽涉三邊:
跨來源 Request 還要帶登入 Cookie
Frontend
├─ withCredentials: true
└─ 告訴瀏覽器:
這次 Request 需要一起處理 Cookie
Backend
├─ origin: ...
├─ credentials: true
└─ 告訴瀏覽器:
這個 Origin 被允許,
而且允許帶 Credentials 的跨來源請求
Cookie
├─ sameSite: ...
├─ secure: ...
└─ 瀏覽器還會檢查:
這顆 Cookie 自己的規則是否允許
這裡程式碼裡的 Credentials,在 BuJo 這個情境裡,可以先把它理解成:
包含登入 Cookie 的這類身分資訊。
也就是說,不是只改一個地方就好:
Frontend、Backend 和 Cookie 自己的規則,都會參與這次跨來源登入 Request。
知道瀏覽器最後看的其實是 URL 之後,我當時又冒出一個問題:
「如果把 Frontend 和 Backend 一起包進 Docker,是不是就能避免不同 Origin 的問題?」
答案是:
不一定。
因為 Docker 和前面的瀏覽器規則,其實不是同一層的東西。
Docker
→ 決定服務怎麼被容器化、執行和部署
瀏覽器
→ 不知道服務是不是跑在 Docker
→ 只看最後實際使用的 URL
所以即使 Frontend 和 Backend 都跑在 Docker 裡:
https://example.com:5173
https://example.com:3000
Port 還是不一樣。
對瀏覽器來說,照樣是 Cross-Origin(跨來源)。
那如果真的想讓瀏覽器看到同一個 Origin,關鍵就不是:
「有沒有用 Docker。」
而是:
「對外的 URL 最後怎麼被安排。」
這時才會碰到另外一層的部署機制,例如:
反向代理(Reverse Proxy)和網域/路徑路由(Domain / Path Routing)。
它們不是 Docker 裡面的東西,只是部署時常常會一起出現。
例如把架構安排成:
https://bujo.live/
→ Frontend
https://bujo.live/api/
→ Reverse Proxy
→ Backend
瀏覽器最後看到的都是:
https://bujo.live
才真的可能變成 Same-Origin(同源)。
所以可以把這三件事分得很清楚:
Docker
→ 服務怎麼被封裝與執行
Reverse Proxy / Routing
→ 外部 Request 要被導到哪個服務
瀏覽器
→ 只看最後呈現出來的 URL
→ 再根據 URL 判斷 Origin
最後把這些規則收回同一張圖:
Frontend 呼叫 Backend
↓
瀏覽器判斷是不是跨 Origin
├─ 否
│
└─ 是
└─ CORS
如果還要帶登入 Cookie
├─ Frontend
│ └─ withCredentials
│
├─ Backend
│ └─ CORS 允許 Credentials
│
└─ Cookie
└─ SameSite / Secure / Domain ...
其中:
Origin ≠ Site
SameSite 看的是 Site
這張圖不是說瀏覽器內部真的固定照這個順序一格一格執行。
它比較像是:
當前後端分離的登入 Request 出問題時,可以拿來整理「現在到底牽涉到哪幾套規則」的地圖。
以前如果 AI 跟我說:
「把 Token 從 localStorage 改成 HttpOnly Cookie 會比較安全。」
我大概會先照著改。
但現在回頭看,我比較能分清楚:這不是單純把 Token 從 A 搬到 B,而是換了一種風險模型。
localStorage
→ JavaScript 可以直接讀
→ 要注意 XSS 偷走 Token
HttpOnly Cookie
→ JavaScript 不能直接讀
→ 但又要處理 SameSite、Secure、CORS 等規則
所以真正要判斷的不是:
「哪一種做法最安全?」
而是:
「這個做法解決了哪一種風險,又帶來哪些新的條件?」
這也是我現在覺得,單純「叫 AI 幫我改」和自己真的理解這段 Code 最大的差別。
AI 可以很快幫我把功能改完,但如果不知道為什麼要改、瀏覽器接下來還會檢查哪些規則,我其實還是很難判斷它到底為什麼能運作。
到這裡我才更確定:
登入不是拿到 Token 就結束。
Token 怎麼傳、怎麼存,以及瀏覽器在什麼條件下願意把它帶回 Backend,都是登入設計的一部分。
明天,我們就從登入這條長長的旅程跳回 API 本身。
這幾天一直說 Request、Response、Endpoint,到底什麼才叫 API?下一篇再把這塊正式拆開。