Web Security for Frontend Devs · Part 6 — CORS Explained
CORS is not a firewall for your API — it relaxes SOP so browsers can read cross-origin responses when the server opts in. Preflight, credentials, misconfigurations, and what CORS cannot fix — with exercises.
Part 6 of 10 in the Web Security for Frontend Devs series {Phần 6/10 trong series Web Security for Frontend Devs}. Previous {Trước}: Part 5 — Auth Tokens & Secure Storage · Next {Tiếp}: Part 7 — Clickjacking & Framing.
This is Part 6 of a 10-part series on the web-security essentials every frontend developer should know — and actively prevent {Đây là Phần 6 của series 10 bài về những kiến thức bảo mật web mà mọi frontend dev nên biết — và chủ động phòng tránh}. If you have heard “enable CORS to secure the API,” you have the model backwards {Nếu bạn từng nghe “bật CORS để bảo vệ API,” bạn đang hiểu ngược mô hình}. Cross-Origin Resource Sharing (CORS) is a browser mechanism that relaxes the Same-Origin Policy from Part 1 so JavaScript on one origin can read a cross-origin response — but only when the responding server explicitly allows it {Cross-Origin Resource Sharing (CORS) là cơ chế trình duyệt nới lỏng Same-Origin Policy ở Phần 1 để JavaScript trên một origin đọc phản hồi cross-origin — nhưng chỉ khi server phản hồi cho phép rõ ràng}. Your API still needs its own authentication and authorization; CORS is not that layer {API của bạn vẫn cần xác thực và phân quyền riêng; CORS không phải lớp đó}.
The #1 misconception {Hiểu lầm số 1}
CORS does not protect your server from attackers. {CORS không bảo vệ server khỏi kẻ tấn công.}
Attackers are not limited to browser fetch from a victim tab {Kẻ tấn công không bị giới hạn bởi fetch trình duyệt từ tab nạn nhân}. They use curl, server-side scripts, compromised backends, and mobile apps — none of which enforce CORS {Họ dùng curl, script phía server, backend bị xâm nhập, app mobile — không cái nào áp CORS}. CORS only constrains honest web pages running in a victim’s browser when they try to read a response with JavaScript {CORS chỉ ràng buộc trang web hợp lệ chạy trong trình duyệt nạn nhân khi chúng cố đọc phản hồi bằng JavaScript}.
Think of it this way {Hãy nghĩ như sau}:
| Layer | Who enforces it | What it does |
|---|---|---|
| SOP (default) | Browser | Blocks JS from reading cross-origin responses |
| CORS | Browser + server’s Access-Control-* headers | Opt-in to allow specific origins to read |
| Authn / authz | Your server | Decides whether the caller may perform the action |
Mental model {Mô hình tư duy}: CORS is a read permission for browser JS, not an API firewall {CORS là quyền đọc cho JS trình duyệt, không phải tường lửa API}. The server that owns the data publishes who may see responses in a browser context {Server sở hữu dữ liệu công bố ai được xem phản hồi trong ngữ cảnh trình duyệt}.
SOP recap: send vs read {Ôn SOP: gửi vs đọc}
From Part 1: the browser will send many cross-origin requests (including with cookies), but blocks your page’s JavaScript from reading the body unless CORS allows it {Từ Phần 1: trình duyệt vẫn gửi nhiều request cross-origin (kể cả kèm cookie), nhưng chặn JS trên trang bạn đọc body trừ khi CORS cho phép}.
https://app.example.com (your SPA)
│
│ fetch('https://api.example.com/me', { credentials: 'include' })
▼
https://api.example.com (API)
│
├─ Request arrives (cookies may attach) ← SOP does NOT stop this
└─ Response body hidden from JS unless ← CORS decides read access
Access-Control-Allow-Origin allows app.example.com
That send/read split is why CSRF (Part 4) and CORS solve different problems {Khe tách gửi/đọc đó là lý do CSRF (Phần 4) và CORS giải hai vấn đề khác nhau}.
Simple requests vs preflighted requests {Request đơn giản vs cần preflight}
The browser classifies some cross-origin fetch/XHR as simple (no preflight) and everything else triggers an OPTIONS preflight {Trình duyệt phân loại một số fetch/XHR cross-origin là đơn giản (không preflight), phần còn lại kích hoạt OPTIONS preflight}.
A cross-origin request is simple only when all of these hold {Request cross-origin đơn giản chỉ khi tất cả điều kiện sau đúng}:
- Method is
GET,HEAD, orPOST{Phương thức làGET,HEAD, hoặcPOST}. - Only CORS-safelisted request headers (e.g.
Accept,Content-Typewith allowed values,Content-Language, …) {Chỉ header request trong safelist CORS (vdAccept,Content-Typevới giá trị cho phép, …)}. - If
Content-Typeis set onPOST, it must beapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain{NếuPOSTcóContent-Type, phải làapplication/x-www-form-urlencoded,multipart/form-data, hoặctext/plain}.
Preflight is required when you cross any of those lines — typical triggers {Cần preflight khi vượt bất kỳ ranh giới nào — kích hoạt thường gặp}:
- Methods like
PUT,PATCH,DELETE{Phương thứcPUT,PATCH,DELETE}. - Headers like
Authorization,X-CSRF-Token, or any custom header {Header nhưAuthorization,X-CSRF-Token, hay header tùy chỉnh}. Content-Type: application/jsononPOST{Content-Type: application/jsontrênPOST}.
Example preflight — browser asks before the real PATCH {Ví dụ preflight — trình duyệt hỏi trước PATCH thật}:
OPTIONS /api/profile HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, authorization
Server must answer with explicit allowance (status 204 or 200 is common) {Server phải trả lời cho phép rõ ràng (status 204 hoặc 200 thường gặp)}:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Only if preflight succeeds does the browser send the real PATCH and expose the response body to JS {Chỉ khi preflight thành công trình duyệt mới gửi PATCH thật và cho JS đọc body phản hồi}.
Key response headers {Header phản hồi quan trọng}
| Header | Role |
|---|---|
Access-Control-Allow-Origin | Which origins may read the response in JS (echo a specific origin, not * when using credentials) |
Access-Control-Allow-Methods | Methods allowed after preflight |
Access-Control-Allow-Headers | Request headers allowed after preflight |
Access-Control-Allow-Credentials: true | Allows cookies / HTTP auth / client certs with credentials: 'include' |
Access-Control-Max-Age | How long the browser may cache preflight result (seconds) |
Access-Control-Expose-Headers | Which response headers beyond the safelist JS may read (e.g. X-Request-Id) |
Access-Control-Allow-Origin must echo one concrete origin (or a dynamic allowlist you validate server-side) — not a blind mirror of untrusted input {Access-Control-Allow-Origin phải echo một origin cụ thể (hoặc allowlist bạn validate phía server) — không phản chiếu mù input không tin cậy}.
# Good — explicit partner SPA
Access-Control-Allow-Origin: https://app.example.com
# Good for public read-only JSON (no cookies / no credentials)
Access-Control-Allow-Origin: *
# Bad with credentials (browser will reject)
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Credentials and CORS {Credentials và CORS}
When the client sends cookies or HTTP auth, three rules must align {Khi client gửi cookie hoặc HTTP auth, ba quy tắc phải khớp}:
- Client:
credentials: 'include'(orwithCredentials: trueon XHR) {Client:credentials: 'include'(hoặcwithCredentials: truetrên XHR)}. - Server:
Access-Control-Allow-Credentials: true{Server:Access-Control-Allow-Credentials: true}. - Server:
Access-Control-Allow-Originis the exact origin string — never*{Server:Access-Control-Allow-Originlà chuỗi origin chính xác — không*}.
const res = await fetch('https://api.example.com/me', {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
If any leg is wrong, DevTools shows a CORS error even when the server returned 200 and Set-Cookie — because the browser withholds the body from JavaScript {Nếu một chân sai, DevTools báo lỗi CORS dù server trả 200 và Set-Cookie — vì trình duyệt không giao body cho JavaScript}. The session may still have changed server-side; your SPA simply cannot read the proof {Phiên vẫn có thể đổi phía server; SPA chỉ không đọc được bằng chứng}.
Dangerous misconfigurations {Cấu hình nguy hiểm}
These are real vulnerabilities, not stylistic nitpicks {Đây là lỗ hổng thật, không phải góp ý phong cách}.
Reflecting Origin blindly with credentials {Phản chiếu Origin mù kèm credentials}
Vulnerable pattern {Mẫu dễ tổn thương}:
// NEVER: trusts any Origin when credentials are enabled
function setCors(res: { setHeader: (k: string, v: string) => void }, req: { headers: { origin?: string } }) {
const origin = req.headers.origin ?? '';
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
Any site the victim visits can set Origin: https://evil.com, get Access-Control-Allow-Origin: https://evil.com, and — with credentials: 'include' — read authenticated JSON from your API in the victim’s browser {Bất kỳ trang nạn nhân ghé đều gửi Origin: https://evil.com, nhận Access-Control-Allow-Origin: https://evil.com, và với credentials: 'include' — đọc JSON đã xác thực từ API trong trình duyệt nạn nhân}.
Fix — constant-time check against a fixed allowlist, echo only on match {Sửa — kiểm tra allowlist cố định, chỉ echo khi khớp}:
const ALLOWED = new Set(['https://app.example.com', 'https://staging.example.com']);
function setCors(
res: { setHeader: (k: string, v: string) => void },
req: { headers: { origin?: string } },
) {
const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
}
Access-Control-Allow-Origin: * on internal APIs {* trên API nội bộ}
Public read-only endpoints sometimes use * without credentials {Endpoint công khai chỉ đọc đôi khi dùng * không kèm credentials}. Putting * on an internal admin or employee API exposes readable responses to any origin’s JavaScript if someone can trick a logged-in browser into calling it {Đặt * trên API nội bộ admin/nhân viên cho phép JS từ mọi origin đọc phản hồi nếu kẻ xấu lừa trình duyệt đã đăng nhập gọi API}. Lock internal APIs to VPN / mTLS / network policy and tight CORS {Khóa API nội bộ bằng VPN / mTLS / chính sách mạng và CORS chặt}.
Trusting the null origin {Tin origin null}
Sandboxed iframes, file://, and some redirects send Origin: null {iframe sandbox, file://, một số redirect gửi Origin: null}. Allowlisting null is almost always wrong for credentialed APIs {Cho phép null hầu như luôn sai với API có credentials}.
Overly broad allowlists {Allowlist quá rộng}
https://*.example.com via regex mistakes, stale staging domains, or “allow all subdomains” can include attacker-controlled hosts if DNS or tenant signup is loose {https://*.example.com do regex sai, domain staging cũ, hay “cho mọi subdomain” có thể gồm host kẻ tấn công kiểm soát nếu DNS hoặc đăng ký tenant lỏng}. Prefer an explicit set {Ưu tiên tập hợp rõ ràng}.
What CORS does NOT do {CORS KHÔNG làm gì}
Be explicit so you do not ship the wrong defense {Nói rõ để không triển khai nhầm lớp phòng thủ}:
| Myth | Reality |
|---|---|
| ”CORS blocks evil sites from calling my API” | The request still hits your server; only JS read is gated |
| ”No CORS header = secure API” | Non-browser clients ignore CORS entirely |
| ”CORS prevents CSRF” | Part 4 — CSRF is about cookies on send; CORS is about read after send |
| ”CORS authenticates users” | Only your session/JWT/API-key logic does; CORS never validates passwords |
Custom headers can reduce simple CSRF in API-only setups (attackers cannot set Authorization from a form), but that is a side effect — not the purpose of CORS {Header tùy chỉnh có thể giảm CSRF đơn giản trong setup chỉ API (form không set Authorization được), nhưng đó là hiệu ứng phụ — không phải mục đích CORS}. Cookie-based apps still need SameSite + anti-CSRF tokens {App dựa cookie vẫn cần SameSite + token chống CSRF}.
Debugging CORS in DevTools {Debug CORS trong DevTools}
When fetch fails with a message like “blocked by CORS policy” {Khi fetch fail với “blocked by CORS policy”}:
- Open Network → select the failed request (often the
OPTIONSpreflight) {Mở Network → chọn request lỗi (thường là preflightOPTIONS)}. - Compare Request
Origin, method, and requested headers with ResponseAccess-Control-Allow-*{So RequestOrigin, method, header yêu cầu với ResponseAccess-Control-Allow-*}. - Check whether
credentials: 'include'requires an exact origin echo +Allow-Credentials: true{Kiểm tracredentials: 'include'có cần echo origin chính xác +Allow-Credentials: truekhông}. - Remember: fix the API (or gateway) headers, not the SPA “turning off CORS” — there is no client-side switch that safely bypasses SOP for production traffic {Nhớ: sửa header API (hoặc gateway), không phải SPA “tắt CORS” — không có công tắc client an toàn bỏ qua SOP cho traffic production}.
Browser extensions and “CORS unblock” plugins train bad habits and do not represent real users {Extension trình duyệt và plugin “CORS unblock” dạy thói xấu và không đại diện user thật}. Reproduce in a clean profile {Tái hiện trên profile sạch}.
Frontend checklist {Checklist frontend}
Before you blame the framework {Trước khi đổ lỗi framework}:
- Know whether the call is simple or preflighted — JSON
POSTis usually preflighted {Biết call đơn giản hay preflight —POSTJSON thường cần preflight}. - Match
credentialson the client toAllow-Credentials+ exact origin on the server {Khớpcredentialsclient vớiAllow-Credentials+ origin chính xác server}. - Never ask backend to “reflect Origin” without an allowlist review {Đừng bảo backend “reflect Origin” mà không review allowlist}.
- Keep CSRF defenses for cookie sessions even when CORS is strict {Giữ phòng CSRF cho phiên cookie dù CORS chặt}.
- Treat CORS as documentation for browsers, authz as law for everyone {Coi CORS là hướng dẫn cho trình duyệt, authz là luật cho mọi client}.
Bài tập / Exercises
1. In one sentence: does CORS stop evil.com from sending a POST with the victim’s cookies to api.bank.com? Does it stop evil.com from reading the JSON response in JS? {Một câu: CORS có chặn evil.com gửi POST kèm cookie nạn nhân tới api.bank.com không? Có chặn evil.com đọc JSON phản hồi bằng JS không?}
Solution {Lời giải}
Send: No — the browser still delivers the request (CSRF territory, Part 4) {Gửi: Không — trình duyệt vẫn chuyển request (miền CSRF, Phần 4)}. Read: Yes, by default — unless api.bank.com sends CORS headers that include evil.com, JS cannot read the body {Đọc: Có, mặc định — trừ khi api.bank.com gửi header CORS cho phép evil.com, JS không đọc được body}.
2. Why does fetch('https://api.example.com/user', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include' }) from https://app.example.com almost always produce an OPTIONS request first? {Vì sao fetch đó từ https://app.example.com hầu như luôn sinh OPTIONS trước?}
Solution {Lời giải}
PATCH is not a simple method, application/json is not a simple Content-Type, and credentialed cross-origin requests need explicit allowance — the browser preflights before the real request {PATCH không phải method đơn giản, application/json không phải Content-Type đơn giản, request cross-origin có credentials cần cho phép rõ — trình duyệt preflight trước request thật}.
3. Spot the bug: the API sets Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true. What should production use instead for a cookie session SPA at https://app.example.com? {Tìm lỗi: API đặt Access-Control-Allow-Origin: * và Access-Control-Allow-Credentials: true. Production nên dùng gì cho SPA cookie tại https://app.example.com?}
Solution {Lời giải}
Browsers reject * with credentials {Trình duyệt từ chối * kèm credentials}. Use Access-Control-Allow-Origin: https://app.example.com (only when Origin matches allowlist), Access-Control-Allow-Credentials: true, and Vary: Origin {Dùng Access-Control-Allow-Origin: https://app.example.com (chỉ khi Origin khớp allowlist), Access-Control-Allow-Credentials: true, và Vary: Origin}.
Stretch {Nâng cao}: Audit this pseudo-middleware. List every issue and rewrite it safely {Audit pseudo-middleware này. Liệt kê mọi lỗi và viết lại an toàn}:
function cors(req: { headers: { origin?: string } }, res: { setHeader: (k: string, v: string) => void }) {
const o = req.headers.origin;
if (o === 'null' || o?.endsWith('.example.com')) {
res.setHeader('Access-Control-Allow-Origin', o ?? 'null');
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Access-Control-Allow-Origin', '*');
}
Solution {Lời giải}
Issues: reflects any subdomain of example.com (typosquat / tenant risk); allows null with credentials; sets * after credentialed echo (confusing / wrong); no Vary: Origin; no Allow-Methods / Allow-Headers for preflight {Lỗi: reflect mọi subdomain example.com; cho null kèm credentials; đặt * sau echo credentials; thiếu Vary: Origin; thiếu Allow-Methods / Allow-Headers cho preflight}. Safe approach: fixed Set of full origins, echo only on match, never * with credentials, handle OPTIONS explicitly {Cách an toàn: Set origin đầy đủ, chỉ echo khi khớp, không * với credentials, xử lý OPTIONS rõ ràng}.
Key takeaways {Điểm chính}
- CORS relaxes SOP for reads — it does not protect your server from non-browser clients {CORS nới SOP để đọc — không bảo vệ server khỏi client không phải trình duyệt}.
- The browser sends cross-origin requests; CORS controls whether your JS may read the response {Trình duyệt gửi request cross-origin; CORS quyết định JS của bạn có đọc phản hồi không}.
- Simple vs preflight —
PUT/PATCH/DELETE, custom headers, andapplication/jsontriggerOPTIONS{Đơn giản vs preflight —PUT/PATCH/DELETE, header tùy chỉnh,application/jsonkích hoạtOPTIONS}. - Credentials need exact origin echo +
Allow-Credentials: true— never*{Credentials cần echo origin chính xác +Allow-Credentials: true— không*}. - Blind Origin reflection with credentials lets attacker origins read authenticated API data in the victim’s browser {Phản chiếu Origin mù kèm credentials cho origin kẻ tấn công đọc dữ liệu API đã xác thực trong trình duyệt nạn nhân}.
- CORS ≠ CSRF defense — see Part 4; fix CORS errors on the server {CORS ≠ phòng CSRF — xem Phần 4; sửa lỗi CORS ở server}.
Next up {Tiếp theo}
Part 7 — Clickjacking & Framing: when attackers stack invisible iframes over your UI, and how X-Frame-Options / CSP frame-ancestors lock the page out of foreign frames {Phần 7 — Clickjacking & Framing: khi kẻ tấn công xếp iframe vô hình lên UI của bạn, và X-Frame-Options / CSP frame-ancestors khóa trang khỏi frame lạ}. Continue to Part 7 — Clickjacking & Framing.