主題: Web platform
CORS 從頭學:請求送出、Cookie 與回應可讀,分開看才不會亂改
用白話流程與 fetch 範例,拆解 origin、OPTIONS 預檢、credentials、Cookie、Allow/Expose 標頭及 DevTools 除錯。理解 API 已執行卻讀不到回應的原因。
動態迷因(展開/收合)
「Postman 明明可以,為什麼網頁不行?」看到 CORS 錯誤時,最容易開始亂改的地方,就是把前端 fetch、API 回應標頭與 Cookie 屬性當成同一組設定。
它們管的是不同階段。本文用一個網頁 https://app.example.test 呼叫 API https://api.example.test 的案例,逐步把參數放回正確位置。網址、token 和資料都是示意,不是可直接呼叫的服務。
我會先確認「哪一段失敗」,再改設定。因為網頁讀不到回應,不代表 API 沒收到請求,更不代表寫入已回滾。
1. 瀏覽器在保護誰?
如果你登入某個網站,另一個不可信網頁不應隨意讀出你的私人 API 資料。同源政策限制網頁跨來源讀取;CORS 則讓 API 用回應標頭,明確允許特定網頁來源讀取結果。
Postman、curl 與後端程式沒有同樣的網頁讀取限制。它們成功,能證明 API 在那次呼叫中有回應,不能證明瀏覽器已獲許可。MDN:同源政策
把一次呼叫拆成幾個問題,會比較好判讀:
- 瀏覽器有沒有送出實際請求?
- API 有沒有通過身分驗證與資料權限檢查?
- API 有沒有執行操作?
- 瀏覽器有沒有把回應交給 JavaScript?
最後一項失敗,前面仍可能成功。尤其是新增訂單、送出表單這類寫入,前端報錯後不能直接假設可以安全重試。
2. origin 是來源,不是完整網址
對一般 HTTP(S) 網址,origin 比較的是 scheme、hostname 與有效 port。路徑、query 與 hash 不算。
與 https://app.example.test 比較 |
同源? | 理由 |
|---|---|---|
https://app.example.test/settings?tab=2 |
是 | 路徑和 query 不影響來源 |
https://app.example.test:443/ |
是 | 443 是 HTTPS 的預設 port |
https://app.example.test:8443/ |
否 | port 不同 |
http://app.example.test/ |
否 | scheme 不同 |
https://api.example.test/ |
否 | hostname 不同 |
所以兩個 localhost 服務用了不同 port,也不同源。程式裡可以這樣確認:
new URL("https://app.example.test:443/settings?tab=2").origin;
// "https://app.example.test"
Access-Control-Allow-Origin 要填這種來源,沒有路徑或最後的斜線。MDN:URL.origin
3. 哪些設定在前端,哪些在 API?
| 位置 | 例子 | 工作 |
|---|---|---|
| 前端 fetch 選項 | credentials: "include" |
允許使用合格的跨來源 credentials |
| 前端 request header | Authorization: Bearer demo-token |
把明確的認證資訊送給 API |
| API response header | Access-Control-Allow-Origin |
允許這次網頁來源讀回應 |
| Cookie 屬性 | HttpOnly、SameSite、Secure |
限制 Cookie 的存取與傳送 |
把 Allow-Origin 寫進 request,不會取得伺服器許可。一般網頁也不能任意偽造瀏覽器管理的 Origin 或 Cookie。本文的 request 示意會列出瀏覽器產生的標頭,但不表示它們都能由 JavaScript 自行設定。
4. 一般 GET 怎麼走?
讀取公開商品清單,可以先看這個前端範例:
const response = await fetch("https://api.example.test/products", {
credentials: "omit",
});
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
const products = await response.json();
它沒有額外標頭,GET 也沒有 body。一般 CORS 流程是:
瀏覽器送 GET → API 處理並回應 → 瀏覽器檢查 CORS → JavaScript 取得可讀結果
API 可以限制給指定來源:
Access-Control-Allow-Origin: https://app.example.test
Content-Type: application/json
公開、不使用 credentials 的資料也可以回 Access-Control-Allow-Origin: *。每次回應只能使用一個來源或適用的星號,不能以逗號列出兩個來源。支援多個網站時,要先檢查可信清單,再回當次匹配的來源;不能無條件反射任意 Origin。MDN:Allow-Origin
這種不用預檢的請求常被稱為 simple request。名稱不代表操作安全,也不代表省掉了回應讀取檢查。MDN:CORS
5. OPTIONS 預檢看的是送出的請求
瀏覽器會先用 OPTIONS 詢問部分跨來源請求是否被允許。常見觸發條件是 PUT/PATCH/DELETE、程式設定 Authorization 或自訂標頭,以及 request 的 Content-Type: application/json。
一般免預檢必須符合全部相關條件,包含 GET/HEAD/POST、safelisted 標頭及其值的限制。請求 Content-Type 的常見合格 MIME type 是 application/x-www-form-urlencoded、multipart/form-data、text/plain。不只是看標頭名字。MDN:safelisted request headers
JSON 最容易搞混,先把方向寫出來:
| 設定 | 意思 |
|---|---|
Request Content-Type: application/json |
我送的 body 是 JSON |
Request Accept: application/json |
我偏好收到 JSON |
Response Content-Type: application/json |
API 回來的 body 是 JSON |
API 回 JSON,本身不會使請求需要預檢。前端用 POST 送 JSON,才是上面提到的請求條件。
6. GET 加 Authorization,沒有 body 也會預檢
const response = await fetch("https://api.example.test/profile", {
headers: { Authorization: "Bearer demo-token" },
credentials: "omit",
});
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
const profile = await response.json();
Authorization 不在一般 safelist。沒有可重用的預檢許可時,瀏覽器先送:
OPTIONS /profile HTTP/1.1
Origin: https://app.example.test
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization
最後一行只列標頭名稱,不包含 Bearer token 的值。API 的預檢回應可像這樣:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Headers: Authorization
通過後,瀏覽器才送帶 token 的實際 GET。API 要驗證 token 值與資料權限,並在實際回應提供 CORS 標頭。
Allow-Headers 允許的是標頭名稱,不會讓 token 自動有效。Authorization 也是 wildcard 特例,必須明確列出,不能只靠 Allow-Headers: *。上面的 omit 不會刪除程式自己設定的 Authorization;它與瀏覽器自動攜帶 Cookie 的處理不同。MDN:Allow-Headers、Fetch Standard
這裡要分清楚規格與實作:瀏覽器對這個 wildcard 特例的支援並不一致。某個版本接受星號,不代表這份設定符合規格,也不代表換個瀏覽器還能運作。實際設定仍應明列 Authorization,而不是依賴寬鬆的實作行為。MDN 相容性資料
7. 預檢成功後,實際回應仍要過關
需要預檢,且沒有可重用許可
↓
OPTIONS 詢問來源/方法/標頭
├─ 不通過:這輪不送後續實際請求
└─ 通過:送實際請求 → API 處理 → 檢查實際回應 CORS
├─ 符合:網頁可讀
└─ 不符合:網頁不可讀,操作可能已完成
OPTIONS 回 204 只是狀態成功,不等於許可內容完整。PUT 還需要相符的方法許可,例如 Access-Control-Allow-Methods: PUT。預檢只完成前一階段,不能取代實際回應的 Allow-Origin 或適用的 credentials 許可。
不要只替成功回應加 CORS。API 的 401、500,以及閘道產生的錯誤回應,也需要依來源政策處理;否則瀏覽器的 CORS 訊息可能遮住上游真正的故障。
8. Cookie 能送,與回應能讀,是兩件事
credentials 主要控制瀏覽器管理的 credentials,也影響回應的 Set-Cookie 是否被採用:
| 值 | 行為 |
|---|---|
same-origin |
預設,只在同源請求使用 |
include |
跨來源也可使用,仍受 Cookie 與瀏覽器政策限制 |
omit |
不使用,即使是同源 |
Cookie session API 的前端可以寫:
const response = await fetch("https://api.example.test/me", {
credentials: "include",
});
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
const me = await response.json();
這不保證 Cookie 存在、合格或登入成功。只是不能漏掉的前端條件。MDN:Request.credentials
對跨來源 include 模式,API 的許可需要:
Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Credentials: true
Allow-Origin: * 加上 Allow-Credentials: true 仍無效。也不能說「這次剛好沒 Cookie,所以星號可以」;CORS 判斷的是 credentials mode,不是只看本次 Cookie 是否存在。HttpOnly 不會修好這個組合。MDN:Allow-Credentials、Fetch:credentials 與來源分享規則
一般 GET 不會只因 include 就必然預檢。若需要預檢,普通預檢不帶登入 Cookie,但伺服器仍須在許可中允許後續 include 模式;真正 API 的認證與權限檢查保留在實際請求。
動態迷因(展開/收合)
9. HttpOnly、SameSite、Secure 各管哪裡?
Set-Cookie: session=demo-value; Path=/; Secure; HttpOnly; SameSite=Lax
這是示意,不是讓所有系統都照抄的登入設定。
| 條件 | 在檢查什麼 |
|---|---|
| host/Domain | Cookie 是否適用這次 API 主機;沒有 Domain 通常為 host-only |
| Path、到期時間 | 路徑是否相符,Cookie 是否仍有效 |
| Secure | 安全傳輸條件,正式環境使用 HTTPS |
| HttpOnly | script 不能直接讀 Cookie 值 |
| SameSite | 站點情境是否允許自動帶 Cookie |
| 瀏覽器政策 | 第三方 Cookie 是否受限制 |
HttpOnly 不阻止符合條件的 Cookie 隨 fetch 自動傳送。跨來源也不必然跨站;一般而言,上述 app 與 api 是不同 origin,但可屬於相同 schemeful site。site 比較 scheme 與可註冊網域,和 origin 的 hostname/port 比較不同。MDN:Site
明確跨站 fetch 時,SameSite=Lax 不會因 include 就放行。SameSite=None 必須搭配 Secure,而且第三方 Cookie 政策仍然有效。不要為了登入問題移除 HttpOnly,或不經評估就把所有 Cookie 改成 None。
Set-Cookie 本身也不能公開給前端 script;即使列入 Expose-Headers,仍無法透過 response.headers.get("Set-Cookie") 讀原文。Network 看見、瀏覽器儲存與 script 可讀,是不同範圍。MDN:Set-Cookie
10. Allow-Headers 和 Expose-Headers 別放反
假設 request 帶 X-Client-Version,response 回 X-Next-Cursor:
Access-Control-Allow-Headers: X-Client-Version
Access-Control-Expose-Headers: X-Next-Cursor
第一行允許送出去的 request header。第二行讓 script 讀回來的 response header,例如 response.headers.get("X-Next-Cursor")。body 能讀,不代表每個 header 都能讀;Network 面板的可見範圍也不等於 JavaScript。MDN:Expose-Headers
其餘常見標頭可以這樣回查:
| 標頭 | 誰設定 | 工作 |
|---|---|---|
| Origin | 瀏覽器 | 發起網頁的來源 |
| Access-Control-Request-Method/Headers | 瀏覽器預檢 | 預計使用的方法/非 safelisted 標頭名稱 |
| Access-Control-Allow-Origin | API 回應 | 允許這次來源 |
| Access-Control-Allow-Methods/Headers | API 預檢回應 | 允許需要確認的方法/標頭 |
| Access-Control-Allow-Credentials | API 適用回應 | 允許 include 模式分享回應 |
| Access-Control-Expose-Headers | API 實際回應 | 額外可供 script 讀取的回應標頭 |
11. 沒看到 OPTIONS,與 no-cors 是不同問題
Access-Control-Max-Age 快取的是預檢許可,不是 API body。符合已有許可的請求可能不再送 OPTIONS,瀏覽器也有自己的時間上限。新增未被允許的標頭,不能直接沿用舊許可。MDN:Max-Age
API 依 Origin 改變回應時,要處理快取的來源版本區分,例如在既有 Vary 裡加入 Origin。Vary: Origin 不授予 CORS 許可,也不表示私人資料可以公開快取。MDN:Vary
mode: "no-cors" 也不會把 JSON API 變成可讀。跨來源 opaque 回應對 script 顯示 status 0、body null,無法讀到原始內容。改用 text 再 JSON.parse 不會救回隱藏的 body;方法與標頭本身也受限制。MDN:Response.type
12. CORS 不會替你驗證使用者或防住所有 CSRF
來源許可、使用者認證與資料授權各自需要檢查。Origin 不是使用者身分;非瀏覽器程式可以自行構造標頭,不能只因 Origin 相符就允許私人資料。
CSRF 可以利用瀏覽器自動附帶的登入資訊,促成使用者沒打算做的操作。攻擊者不一定需要讀回應。因此沒有 CORS 許可,不等於寫入端點已防 CSRF。要依系統使用 CSRF token、來源檢查、Cookie 政策等合適防護。MDN:CSRF
13. 實務除錯順序
我會從 Network 找證據,不會先把所有星號加上去:
- 比較頁面 origin 與 API URL,也確認 proxy 或 redirect 是否改變目標。
- 有 OPTIONS 時,核對預計 method/headers 與預檢許可;不要只看 204。
- 有實際請求時,查 API 狀態、伺服器紀錄與實際回應的 CORS。寫入失敗訊息不等於沒有寫入。
- Cookie 沒帶上時,檢查儲存狀態、host/path、credentials、SameSite、HTTPS 與瀏覽器政策。
- body 正常但 header 讀不到,檢查 Expose-Headers 與禁止公開的標頭。
- 連閘道與錯誤回應一起看,不只檢查成功路徑。
如果 CORS 正確,HTTP 401/500 通常仍會讓 fetch 取得 Response,而不是只因狀態碼就 reject。範例裡的 response.ok 檢查不能省。反過來,catch 的 TypeError 本身也不足以辨認是 CORS 還是網路失敗。MDN:Using Fetch
開發 proxy 可讓瀏覽器看到同源請求,方便開發,但不能證明正式環境的跨來源設定正確。部署前要用真實瀏覽器測允許與拒絕來源、Cookie 行為,以及 script 的 body/header 可讀結果。
我學到什麼
- 我會把送出請求、API 執行與網頁讀回應分開判斷,避免把 CORS 錯誤當成安全重送的理由。
- include 是 credentials 模式,不能取代 Cookie 條件,也不能搭配萬用 Allow-Origin;HttpOnly 管的是直接讀取。
- 我會先辨認標頭方向。Authorization 需要明確的 Allow-Headers,回應游標則需要 Expose-Headers。
- 修正 CORS 後,認證、資料權限與 CSRF 仍要獨立驗證。
本文聚焦一般 HTTP API 的瀏覽器 fetch,不包含 WebSocket、特殊 origin、跨來源隔離或私有網路存取的完整規則。程式片段是前端與 HTTP 行為示意,不是完整的 production 認證實作。
外部參考資料
- MDN:CORS,用來查預檢與完整交換流程。
- WHATWG Fetch Standard,核對 credentials 與 wildcard 的精確規則。
- MDN:Allow-Headers 與 Allow-Credentials,實作時分開回查。
- Web Dev Simplified:CORS 入門,適合先建立瀏覽器與伺服器的分工,再用本文各節官方連結查細節。