Gần như ai làm web cũng từng gặp dòng đỏ trong console: "Access to fetch ... has been blocked by CORS policy". Và phản xạ đầu tiên thường sai: "server chặn mình", "tắt CORS cho nhanh", hoặc tệ hơn "CORS là lớp bảo mật của server". Ba cách hiểu này đều lệch. CORS (Cross-Origin Resource Sharing) là cơ chế của trình duyệt, không phải của server — và nó nới lỏng chứ không siết chặt. Bài này (phần 14 loạt "HTTP và web đằng sau") dựng một server thật chỉ cho phép đúng một origin, rồi đo bằng curl để thấy rõ ai chặn ai.
Same-origin policy: mặc định trình duyệt chặn đọc chéo
Nền tảng của mọi chuyện là same-origin policy. Origin gồm ba phần: giao thức + tên miền + cổng. Khác một trong ba là khác origin — https://web.vn và http://web.vn khác origin (khác giao thức), https://web.vn và https://web.vn:8443 cũng khác (khác cổng).
Theo mặc định, JavaScript chạy ở https://web.vn không được đọc response từ https://api.khac.vn. Vì sao? Để một trang web độc không thể dùng trình duyệt của bạn — đang đăng nhập ngân hàng ở tab khác — để đọc trộm dữ liệu từ site bạn đang có phiên. Đây là điểm mấu chốt hay bị bỏ qua: việc chặn xảy ra ở TRÌNH DUYỆT, không phải ở server. curl, Postman, hay bất kỳ HTTP client nào không phải trình duyệt đều không bị same-origin policy — chúng gửi request và đọc response bình thường.
CORS: server chủ động cho phép qua header
Same-origin policy quá chặt cho web hiện đại: frontend ở một tên miền gọi API ở tên miền khác là chuyện thường ngày. CORS là cách server chủ động nói với trình duyệt "origin này được phép đọc response của tôi", bằng các header:
Access-Control-Allow-Origin: https://web.tin-cay.vn
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Trình duyệt gửi request kèm header Origin, nhận response, rồi kiểm tra header Access-Control-Allow-Origin có khớp origin của trang không. Khớp thì cho JS đọc; không khớp (hoặc không có header) thì chặn JS đọc và ném lỗi CORS trong console — dù server đã trả về đầy đủ dữ liệu.

Hình 1: Same-origin policy chặn đọc chéo origin ở phía trình duyệt; CORS là cách server chủ động mở bằng header Access-Control-Allow-*; request "không đơn giản" phải qua preflight OPTIONS hỏi trước.
Preflight: OPTIONS hỏi trước với request "không đơn giản"
Không phải request nào cũng gửi thẳng. CORS chia làm hai loại:
- Request đơn giản (simple request):
GET,HEAD, hoặcPOSTvớiContent-Typelàtext/plain/application/x-www-form-urlencoded/multipart/form-data, và không có header tùy chỉnh. Trình duyệt gửi thẳng, rồi mới kiểm header response. - Request "không đơn giản":
PUT,DELETE,PATCH, hoặc có header nhưAuthorization, hoặcContent-Type: application/json. Với loại này trình duyệt gửi một request preflightOPTIONShỏi trước: "tôi định gọiPOSTvới headerAuthorization, có được không?". Chỉ khi server trả về cho phép, trình duyệt mới gửi request thật.
Preflight tốn thêm một vòng round-trip, nên có Access-Control-Max-Age: nó bảo trình duyệt cache kết quả preflight trong N giây, đỡ phải hỏi lại cho mỗi request. Ta mô phỏng preflight bằng curl:
curl -X OPTIONS \
-H "Origin: https://web.tin-cay.vn" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Authorization" \
http://127.0.0.1:8091/api -i
Đo thật: server chỉ cho phép một origin
Mình dựng một server Python nhỏ (socket thuần) trên 127.0.0.1:8091, cấu hình chỉ chấp nhận origin https://web.tin-cay.vn. Server so header Origin của request với danh sách trắng: khớp thì trả header Access-Control-Allow-*, không khớp thì không trả các header đó (nhưng vẫn phục vụ request bình thường). Dưới đây là output thật từ ba tình huống:

Hình 2: Chạy thật — preflight OPTIONS từ origin được phép trả 204 kèm đủ header Access-Control-Allow-* và Max-Age: 600; request thật từ origin được phép có Allow-Origin; origin lạ ke-la.evil.com vẫn nhận 200 OK nhưng KHÔNG có Allow-Origin → trình duyệt sẽ chặn JS đọc.
Ba điều rút ra từ output thật:
- Preflight OPTIONS từ origin được phép →
204 No Contentkèm đủ bộAccess-Control-Allow-Origin/Methods/HeadersvàAccess-Control-Max-Age: 600. Không có body — preflight chỉ để hỏi phép. - Request thật từ origin được phép →
200 OK+Access-Control-Allow-Origin. Trình duyệt thấy header khớp, cho JS đọc response. - Origin lạ
https://ke-la.evil.com→ vẫn200 OK, nhưng KHÔNG cóAccess-Control-Allow-Origin. Đây là điểm quan trọng nhất: server vẫn trả về nguyên body. Nếu đây làcurlthì kẻ tấn công đọc được hết. Chỉ có trình duyệt — khi thấy thiếu header cho phép — mới từ chối trao response cho JS. CORS chặn ở tầng trình duyệt, không phải chặn request tới server.
Vì sao CORS không phải là bảo mật server
Đây là hiểu lầm tai hại nhất. Nhìn lại tình huống 3: origin lạ vẫn nhận được 200 OK và toàn bộ body. Server không hề từ chối xử lý — nó vẫn chạy logic, vẫn trả dữ liệu. CORS chỉ ngăn JavaScript trong trình duyệt đọc kết quả đó. Suy ra:
- CORS không bảo vệ dữ liệu khỏi client không phải trình duyệt.
curl, script, server khác gọi thẳng API của bạn đều lấy được data bất kể CORS cấu hình thế nào. - Bảo mật thật vẫn phải nằm ở server: xác thực (token, session), phân quyền, kiểm tra đầu vào. CORS chỉ là chính sách cho môi trường trình duyệt.
Access-Control-Allow-Origin: *không phải "lỗ hổng" cho API công khai không cần credentials — nhưng tuyệt đối không dùng*cùngAllow-Credentials: true(spec cấm, trình duyệt từ chối) vì đó mới là chỗ rò rỉ phiên đăng nhập.
Đánh đổi cần cân nhắc
Tắt CORS ở trình duyệt không giải quyết gì cho production. Nhiều người chạy Chrome với --disable-web-security để "hết lỗi CORS" lúc dev. Nó chỉ tắt trên máy bạn; người dùng thật vẫn gặp lỗi. Cách đúng là cấu hình header ở server, hoặc dùng dev proxy để frontend và API cùng origin lúc phát triển.
Preflight thêm độ trễ — cân nhắc Max-Age và tránh header thừa. Mỗi request "không đơn giản" tốn thêm một round-trip OPTIONS. Đặt Access-Control-Max-Age hợp lý (nhưng nhớ trình duyệt có trần cache riêng, ví dụ Chrome tối đa vài giờ) và tránh thêm header tùy chỉnh không cần thiết để giữ request ở dạng "đơn giản" khi có thể.
Danh sách trắng origin, đừng phản chiếu mù Origin. Một mẫu sai phổ biến: đọc header Origin rồi trả lại y nguyên vào Access-Control-Allow-Origin cho mọi origin. Làm vậy thực chất là mở cho tất cả — nếu kèm credentials thì bất kỳ site nào cũng đọc được dữ liệu người dùng đã đăng nhập. Luôn so với danh sách origin tin cậy.
Ba ý mang về
- CORS là cơ chế của trình duyệt, không phải bảo mật server: same-origin policy chặn JS đọc chéo origin ở phía trình duyệt; server chủ động mở bằng header
Access-Control-Allow-*. Đo thật: origin lạ vẫn nhận200 OKvà full body — chỉ trình duyệt mới chặn JS đọc. - Request "không đơn giản" phải qua preflight OPTIONS: PUT/DELETE, header
Authorization,Content-Type: application/jsonkhiến trình duyệt hỏi trước. Đo thật: preflight từ origin được phép trả204+Allow-Methods/Headers+Max-Age: 600để cache đỡ hỏi lại. - Bảo mật thật vẫn ở server: CORS không ngăn
curlhay client khác lấy dữ liệu, nên xác thực/phân quyền phải nằm ở backend; và đừng phản chiếu mùOriginhay dùng*cùngAllow-Credentials.
Nguồn
- MDN Web Docs — Cross-Origin Resource Sharing (CORS): https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
- MDN Web Docs — Same-origin policy: https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
- Fetch Standard (WHATWG) — CORS protocol: https://fetch.spec.whatwg.org/#http-cors-protocol
Phần sau ta đi vào xác thực HTTP: Basic, Bearer token, và cách header Authorization cùng WWW-Authenticate phối hợp — cũng chính là header khiến request phải qua preflight ở bài này.