iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Vibe Coding

夢幻甜品師闖工程世界:Vibe Coding vs 專業開發的 0→1 冒險攻略系列 第 18

Day18|Google 都放你進城了,BuJo 為什麼還要再發一張通行證?從 JWT、Cookie 到 CORS

  • 分享至 

  • xImage
  •  

昨天 Google 已經把使用者送回 BuJo,Backend 也成功驗證了 Google ID Token。

照我以前的想法:

Google 都已經知道我是誰了,那不就登入完成了嗎?

結果還沒有。

因為 Google 認得你,跟 BuJo 接下來每一個 Request 都認得你,是兩件不同的事。

這也是我以前最容易全部混成「Token」的一層。


Google 認證完成,不等於 BuJo 已經建立自己的登入狀態

前兩天我們已經看過 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 以後,到底怎麼把它交給瀏覽器,又放在哪裡?


第一版:JWT 放進 Response Body,再存進 localStorage

早期 BuJo 的流程是:

Backend
↓
簽 BuJo JWT
↓
JWT 放進 HTTP Response Body
↓
Frontend 收到
↓
存進 localStorage

以前我會把 Response BodylocalStorageCookie 都理解成:

「把 Token 放進去的某個地方。」

但它們其實不是同一層。


HTTP Response:先把它想成一個外送包裹

我後來覺得最好懂的方式,是把一個 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
→ 瀏覽器收到之後把資料放在哪裡

後來:JWT 不再交給 JavaScript,而是放進 HttpOnly Cookie

後來 BuJo 把 JWT 的處理改成:

Backend
↓
簽 BuJo JWT
↓
Set-Cookie Response Header
↓
瀏覽器收到
↓
自動保存成 Cookie

這次 JWT 不再放進 Response Body 讓 Frontend JavaScript 自己拿。

而是在 Response Header 裡,由 Set-Cookie 告訴瀏覽器:

「請幫我把這顆東西存成 Cookie。」

套回外送包裹:

以前:
甜點放在包裹裡
→ Frontend 拆開
→ 自己拿去收

後來:
包裹外面的配送標籤直接寫:
「這一份請瀏覽器幫忙放進 Cookie」

localStorage 到底哪裡讓我不安?

我當時會一路回頭查 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 變成無敵,而是不給 JavaScript 直接讀

HttpOnly 是 Cookie 的一個屬性(Attribute)

設了 HttpOnly 之後,JavaScript 就不能透過 document.cookie 直接把那顆 Cookie 讀出來。

所以如果 JWT 放在 HttpOnly Cookie 裡:

XSS 發生
↓
惡意 JavaScript
↓
不能直接讀出這顆 JWT

這確實降低了 Token 被 JavaScript 直接偷走的風險。

但:

HttpOnly 不是「解決 XSS」。

如果惡意 JavaScript 已經成功在你的頁面裡執行,它還是可能利用使用者目前的登入狀態做其他事情。

HttpOnly 主要保護的是:

不要讓 JavaScript 直接把 Cookie 裡的 Token 內容讀走。


那 Cookie 不也有自己的風險嗎?

我知道 Cookie 也可能碰到 CSRF 時,第一個反應其實是:

「那不就是兩個都不安全嗎?」

後來才發現,真正要看的不是哪個方案「完全沒有風險」,而是:

換了一種儲存與傳遞方式之後,風險模型變成什麼?

localStorage 裡的 Token 可以被 JavaScript 讀,因此特別需要注意 XSS 造成的 Token 竊取。

Cookie 則會在符合規則時由瀏覽器自動帶上請求,因此還需要處理另一種風險:

跨站請求偽造(Cross-Site Request Forgery,CSRF)

簡單說,就是攻擊者想辦法讓已經登入的瀏覽器,在使用者不知情的狀況下,對目標網站送出一個帶著登入 Cookie 的請求。

所以換成 Cookie,不代表:

「安全問題消失。」

而是:

我們降低了一種風險,接著要處理 Cookie 自己的安全邊界。


Cookie 裡不只有 JWT,還帶著一組使用規則

把 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 時,分別會碰到的規則。

接下來再一層一層拆。


第一關:Frontend 和 Backend 是不是同一個 Origin?

第一個重要概念叫:

來源(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」全部混在一起了。


不同 Origin 會怎樣?

瀏覽器本身有一套重要安全規則:

同源政策(Same-Origin Policy)

可以先理解成:

不同 Origin 的網頁,不能隨便讀彼此的資料。

不然任意網站只要打開,就可以亂讀你其他網站上的資料。

但前後端分離很常會出現:

Frontend
http://localhost:5173

Backend
http://localhost:3000

明明是同一個產品,對瀏覽器來說卻是不同 Origin。

那 Frontend 還是需要呼叫 Backend 啊。

這時才會碰到下一個概念:

CORS。


那真的需要跨 Origin 呼叫呢?這時才有 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 跟 Site 是同一件事嗎?

這裡還有一組很容易混在一起的概念。

前面已經知道:

Origin
= scheme(通訊協定) + host(主機名稱) + port(連接埠)

三個都一樣,才是同源。

但 Cookie 的 SameSite 看的是另一套判斷:

Site(站點)

所以:

Cross-Origin 不代表一定就是 Cross-Site。

拿 BuJo 自己的部署網址來看會最清楚。

正式環境:不同 Origin,但還是同一個 Site

Frontend
https://bujo.live

Backend
https://api.bujo.live

兩邊的 host 不同:

bujo.live
api.bujo.live

所以它們是:

Cross-Origin(跨來源)

但它們都屬於 bujo.live 這個 Site,而且都是 HTTPS,所以仍然是:

Same-Site(同站)

測試環境:不只不同 Origin,也不同 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 規則,或其他瀏覽器端政策有關。


如果跨來源 Request 還要帶登入 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。


那用 Docker 把前後端包在一起,不就好了?

知道瀏覽器最後看的其實是 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?下一篇再把這塊正式拆開。


上一篇
Day17|明明路都走對了,為什麼還是卡關?從瀏覽器 Bug 看懂 Google 登入怎麼改走另一條路
下一篇
Day 19|前台喊單,後場真的聽得懂嗎?從 API 看懂前後端怎麼用 HTTP 說話
系列文
夢幻甜品師闖工程世界:Vibe Coding vs 專業開發的 0→1 冒險攻略19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言