API chạy ngon lành trong Postman, trong curl — nhưng gọi từ app React thì console đỏ lòm: “blocked by CORS policy”. Bạn copy một config từ StackOverflow, thêm header này bớt header kia, có lúc hết lỗi nhưng data vẫn không đọc được. Bài này giải thích CORS từ gốc: same-origin policy chặn cái gì, vì sao lỗi này CHỈ xảy ra trong browser, và chính xác KHI NÀO browser gửi preflight request — để lần sau bạn sửa đúng chỗ thay vì đoán.
1. Origin là gì và same-origin policy chặn cái gì
Trước khi nói tới CORS, phải hiểu cái mà CORS nới lỏng: same-origin policy (SOP). Đây là luật bảo mật mặc định của browser, và nó xoay quanh khái niệm origin.
1.1. Origin = scheme + host + port
Một origin là bộ ba: scheme (http/https) + host (tên miền) + port. Hai URL cùng origin khi và chỉ khi cả ba phần trùng nhau. Path (/a, /b) không tính.
| URL A | URL B | Cùng origin? | Vì sao |
|---|---|---|---|
http://localhost:3000 | http://localhost:4000 | ❌ Khác | Khác port |
https://example.com | http://example.com | ❌ Khác | Khác scheme |
https://app.example.com | https://example.com | ❌ Khác | Khác host (subdomain cũng tính) |
https://example.com/users | https://example.com/posts | ✅ Cùng | Chỉ khác path — không tính |
Đây là lý do dev hay dính CORS ngay trên máy mình: frontend chạy http://localhost:3000, backend chạy http://localhost:4000 — khác port, nên với browser đó là hai origin khác nhau.
1.2. SOP chặn JavaScript ĐỌC response, không chặn GỬI request
Đây là hiểu lầm phổ biến nhất, và cũng là nuance quan trọng nhất của cả bài. Với một request “đơn giản” (mục 3 sẽ định nghĩa chính xác), same-origin policy không chặn request được gửi đi. Request vẫn tới server, server vẫn chạy handler, vẫn ghi database, vẫn trả response. Browser chỉ làm một việc duy nhất: giấu response đó khỏi JavaScript của trang.
Hệ quả kép, cả hai đều hay bị hiểu sai:
- CORS không bảo vệ server. curl, Postman, mobile app, một script Node — tất cả bỏ qua CORS hoàn toàn, vì SOP là luật của browser, không phải của server. Cái CORS bảo vệ là người dùng: nó ngăn một site lạ mà bạn vô tình mở dùng chính cookie đăng nhập của bạn để đọc data từ một site khác.
- CSRF vẫn tồn tại. Vì request vẫn được gửi (chỉ response bị giấu), một site độc vẫn có thể kích một request “ghi” (POST chuyển tiền chẳng hạn) sang API của bạn kèm cookie. CORS không cản việc đó — cản nó là việc của
SameSitecookie, đã nói trong bài Session Hijacking từ Cookie.
2. CORS: server nới lỏng same-origin policy qua response header
CORS (Cross-Origin Resource Sharing) là cơ chế để server chủ động cho phép browser mở khóa response cho một origin cụ thể. Server làm việc đó bằng một response header: Access-Control-Allow-Origin (viết tắt ACAO). Browser nhận response, đối chiếu ACAO với origin của trang, khớp thì mới cho JavaScript đọc; không khớp thì chặn ở bước đọc.
Để thấy rõ, mình dựng một server Node thuần với hai route (code đầy đủ trong cors-preflight/server.js). Route /open set ACAO, route /closed không set gì.
▶ Chạy thử ở máy bạn: cors-preflight/server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (path === '/open') {
const origin = req.headers.origin;
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin); // mở khóa cho origin này
res.setHeader('Vary', 'Origin');
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ route: '/open', method: req.method }));
}
if (path === '/closed') {
// Không set header CORS nào — nhưng handler VẪN chạy và VẪN trả data
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ route: '/closed', method: req.method }));
}Gọi /open bằng curl kèm header Origin, response có ACAO echo lại đúng origin:
1
2
3
4
5
6
7
8
9
10
GET /open HTTP/1.1
Host: localhost:4050
Origin: https://app.example.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Type: application/json
{"route":"/open","method":"GET"}Còn /closed thì response không có ACAO — nhưng để ý: status vẫn 200, body vẫn có đủ data:
1
2
3
4
5
6
7
8
GET /closed HTTP/1.1
Host: localhost:4050
Origin: https://app.example.com
HTTP/1.1 200 OK
Content-Type: application/json
{"route":"/closed","method":"GET"}curl in ra data bình thường vì curl không có same-origin policy. Nếu chính request /closed này chạy từ JavaScript trong browser (khác origin), server vẫn chạy handler y hệt, nhưng browser thấy thiếu ACAO nên chặn JS đọc res — đúng như mục 1.2 nói.
Thử ngay bằng một endpoint công khai có CORS mở (https://api.github.com trả ACAO *):
1
2
3
4
5
6
7
8
async function demo() {
const res = await fetch('https://api.github.com/repos/nodejs/node');
const data = await res.json();
console.log('full_name:', data.full_name);
console.log('language:', data.language);
console.log('open_issues:', data.open_issues > 0);
}
demo();Đọc được vì GitHub trả Access-Control-Allow-Origin: *. Đổi sang một origin không mở CORS thì JavaScript “mù” — mục 6 sẽ mổ xẻ đúng cái lỗi đó.
3. Preflight request: khi nào browser gửi và giải phẫu nó
Đây là câu hỏi trung tâm: khi nào browser gửi thêm một request OPTIONS trước request thật? Câu trả lời nằm ở ranh giới giữa simple request và preflighted request.
3.1. Simple request: khi nào KHÔNG có preflight
Một request được coi là “simple” (và browser gửi thẳng, không preflight) khi thỏa đồng thời cả ba điều kiện:
- Method ∈
{ GET, HEAD, POST }. - Mọi header bạn tự thêm đều nằm trong danh sách CORS-safelisted request headers:
Accept,Accept-Language,Content-Language,Content-Type,Range(mỗi cái còn có ràng buộc giá trị riêng). - Nếu có
Content-Type, giá trị của nó phải là một trong ba:application/x-www-form-urlencoded,multipart/form-data, hoặctext/plain.
Chỉ cần rơi khỏi một điều kiện là request hết “simple”, và browser tự động gửi preflight trước.
3.2. Rơi khỏi một điều kiện → browser tự gửi OPTIONS
Ba thủ phạm hay gặp nhất:
Content-Type: application/json— thủ phạm số 1 trong thực tế. Đây là lý do một form HTML cổ điển (gửiapplication/x-www-form-urlencoded) không bao giờ preflight, cònfetchgửi JSON thì luôn preflight:application/jsonkhông nằm trong ba giá trị safelisted ở điều kiện 3.- Method
PUT/PATCH/DELETE— không thuộc{ GET, HEAD, POST }. - Header tự chế —
Authorization,X-Request-Id, hay bất kỳ header nào bạn thêm ngoài danh sách safelisted.
Bảng quyết định nhanh:
| Request | Simple hay preflighted? | Vì sao |
|---|---|---|
GET, không header lạ | Simple | Đủ 3 điều kiện |
POST + Content-Type: text/plain | Simple | Content-Type nằm trong safelist |
POST + Content-Type: application/json | Preflighted | JSON không thuộc 3 giá trị cho phép |
PUT bất kỳ | Preflighted | Method ngoài {GET, HEAD, POST} |
GET + header Authorization | Preflighted | Header ngoài safelist |
3.3. Giải phẫu một preflight
Preflight là một request OPTIONS browser tự sinh ra. Nó hỏi trước: “tôi định gửi method này, header này — server có cho không?”. Browser đính kèm ba header khai báo ý định:
1
2
3
4
5
OPTIONS /open HTTP/1.1
Host: localhost:4050
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type,authorizationServer phải trả status 2xx kèm các header cho phép tương ứng, thì browser mới gửi request thật:
1
2
3
4
5
6
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600Điểm khác biệt sống còn so với simple request: nếu server trả 4xx hoặc redirect cho OPTIONS, preflight fail, và request thật KHÔNG BAO GIỜ được gửi. Đây chính là lý do bạn thấy “lỗi CORS ở client” nhưng soi log server thì “chẳng nhận được request nào” — request thật chết ngay từ vòng preflight.
View Mermaid diagram code
sequenceDiagram
participant JS as JavaScript (browser)
participant S as Server
Note over JS,S: Simple request — 1 round-trip
JS->>S: GET /open (kèm Origin)
S-->>JS: 200 + Access-Control-Allow-Origin
Note over JS,S: Preflighted request — 2 round-trip
JS->>S: OPTIONS /open (Access-Control-Request-Method: PUT)
S-->>JS: 204 + Allow-Methods / Allow-Headers
JS->>S: PUT /open (request thật)
S-->>JS: 200 + Access-Control-Allow-Origin4. Access-Control-Max-Age: cache preflight để bớt round-trip
Nếu mỗi request thật đều phải preflight trước thì mọi API call tốn gấp đôi số request. Access-Control-Max-Age giải quyết chuyện đó: nó nói với browser “cache kết quả preflight cho URL này trong N giây”, trong khoảng đó browser bỏ qua OPTIONS và gửi thẳng request thật.
1
Access-Control-Max-Age: 600Nhưng browser có trần cho giá trị này, dù bạn set lớn hơn cũng bị cắt về trần (số liệu MDN, kiểm tra 2026-07-21):
| Browser | Trần Max-Age | Ghi chú |
|---|---|---|
| Chromium/Chrome (từ v76) | 7200 giây (2 giờ) | Trước v76 là 600 giây |
| Firefox | 86400 giây (24 giờ) | |
| Mặc định (không set) | 5 giây | Cache rất ngắn |
Vậy nên set Access-Control-Max-Age: 86400 trên server là hợp lệ, nhưng Chrome vẫn chỉ cache 2 giờ. Đây là cách giảm phân nửa số request cho các API bị preflight — set Max-Age thay vì để mặc định 5 giây.
5. Credentials và luật cấm wildcard
Mặc định, fetch cross-origin không gửi cookie đi kèm — kể cả khi bạn đang đăng nhập. Muốn gửi cookie (hoặc Authorization tự động), cần đủ hai vế:
1
2
// Phía client: yêu cầu gửi cookie
fetch('https://api.example.com/me', { credentials: 'include' });1
2
// Phía server: cho phép nhận credential
res.setHeader('Access-Control-Allow-Credentials', 'true');Và đây là cái bẫy: khi có credentials, Access-Control-Allow-Origin KHÔNG được là *. Browser sẽ chặn với thông báo rõ ràng (mục 6). Server buộc phải echo đúng origin của request:
1
2
3
4
5
6
// ❌ Sai: '*' + credentials → browser chặn
res.setHeader('Access-Control-Allow-Origin', '*');
// ✅ Đúng: echo đúng origin + Vary: Origin
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Vary', 'Origin');Vary: Origin quan trọng khi có cache/CDN ở giữa: nó báo cho cache rằng response phụ thuộc vào header Origin, nên đừng trả response đã cache cho origin A cho một origin B khác. Thiếu nó, CDN có thể phục vụ nhầm ACAO của origin này cho origin kia.
Lưu ý là cookie có SameSite sẽ bị chặn trước cả khi CORS kịp lên tiếng — hai lớp này độc lập nhau, xem Session Hijacking từ Cookie để hiểu SameSite.
6. Đọc lỗi CORS cho đúng: DevTools và bẫy no-cors
6.1. JavaScript chỉ thấy “Failed to fetch” — chi tiết nằm ở DevTools
Điều làm CORS khó debug: từ JavaScript, fetch bị chặn chỉ ném ra một TypeError cụt lủn, không hé lộ lý do.
1
2
3
4
5
6
7
8
9
10
async function demo() {
try {
const res = await fetch('https://www.google.com'); // không mở CORS
console.log('đọc được:', res.status);
} catch (err) {
console.log(err.name); // TypeError
console.log(err.message); // Failed to fetch
}
}
demo();TypeError: Failed to fetch — chấm hết. Muốn biết vì sao, phải mở DevTools → Console, nơi browser tự in ra thông báo đầy đủ. Ba dạng hay gặp (copy từ Chrome):
- Thiếu ACAO:
Access to fetch at '...' from origin '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. - Wildcard với credentials:
... The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. - Preflight không 2xx:
... Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
Sang tab Network, tìm dòng có Method OPTIONS (đó là preflight): xem cột Status của nó, và tab Headers để đối chiếu Access-Control-Request-* (client hỏi) với Access-Control-Allow-* (server trả). Nếu dòng OPTIONS đỏ hoặc thiếu Allow header, lỗi nằm ở preflight chứ không phải request thật.
6.2. Bẫy mode: 'no-cors'
Search “fix CORS” hay ra gợi ý thêm mode: 'no-cors'. Đừng. Nó không mở khóa gì cả — nó chỉ đổi response thành opaque: bạn không đọc được body, status là 0, .json() fail.
1
2
3
4
5
6
7
async function demo() {
const res = await fetch('https://www.google.com', { mode: 'no-cors' });
console.log('type:', res.type); // opaque
console.log('status:', res.status); // 0
console.log('ok:', res.ok); // false
}
demo();no-cors không bao giờ là cách fix khi bạn cần đọc data. Chỗ dùng hợp lệ hiếm hoi: fire-and-forget kiểu tracking pixel — gửi đi mà không quan tâm response.
Cái gì KHÔNG dính CORS
Không phải mọi thứ cross-origin đều bị SOP chặn. <img src>, <script src>, <link> CSS load ảnh/script/style từ origin khác bình thường (chính vì <script> không dính CORS mà JSONP từng tồn tại như một cách lách). <iframe> hiển thị được trang khác — nhưng JavaScript của bạn không với được vào DOM cross-origin bên trong nó. CORS chỉ chặn việc JavaScript đọc response của một fetch/XHR cross-origin.
7. Fix đúng chỗ: server header, dev proxy, reverse proxy
7.1. Cách đúng: server thêm header
Gốc rễ luôn ở server — nó phải khai báo cho phép origin của bạn. Với Node thuần, nhớ xử lý cả OPTIONS (preflight) chứ không chỉ request thật:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const ALLOWED = ['https://app.example.com'];
function applyCors(req, res) {
const origin = req.headers.origin;
if (ALLOWED.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin); // allowlist, không '*'
res.setHeader('Vary', 'Origin');
}
}
// Handler cho preflight
if (req.method === 'OPTIONS') {
applyCors(req, res);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.writeHead(204);
res.end();
return;
}Với Express, middleware cors làm gọn phần này (app.use(cors({ origin: ALLOWED, credentials: true }))) — nó tự trả lời OPTIONS giúp bạn. Nguyên tắc không đổi: allowlist origin thay vì * khi có credentials.
7.2. Lúc dev: proxy để cùng origin
Trên máy dev, cách sạch nhất là để frontend và backend trông như cùng origin bằng proxy của dev server. Vite chuyển hướng /api sang backend, nên với browser mọi request đều same-origin — không có CORS để lo:
1
2
3
4
5
6
7
8
// vite.config.js
export default {
server: {
proxy: {
'/api': { target: 'http://localhost:4000', changeOrigin: true },
},
},
};7.3. Prod: reverse proxy hoặc cùng domain
Ở production, đặt frontend và API sau cùng một domain qua reverse proxy (Nginx, hoặc rewrite của hosting) — ví dụ example.com phục vụ app còn example.com/api trỏ về backend. Cùng origin thì CORS không bao giờ phát sinh.
Và một điều không phải cách fix: extension “tắt CORS” trong browser. Nó chỉ tắt lớp bảo vệ trên máy bạn, không giúp gì cho user thật ngoài kia, và che mất chính cái lỗi bạn cần sửa ở server.
Tóm lại: same-origin policy chặn JavaScript đọc response cross-origin chứ không chặn gửi, nên CORS bảo vệ người dùng chứ không phải server. Browser gửi preflight OPTIONS khi request rơi khỏi nhóm “simple” — thủ phạm hay gặp nhất là Content-Type: application/json, method PUT/DELETE, hay một header tự chế. Và cách fix luôn nằm ở response header của server, không phải ở một cờ nào đó phía client. Lần tới thấy “blocked by CORS policy”, mở tab Network soi dòng OPTIONS trước khi đoán.