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.

Ảnh chụp đoạn mã nền tối minh hoạ CORS là cơ chế trình duyệt không phải bảo mật server, same-origin policy mặc định trình duyệt chặn đọc chéo origin origin là giao thức cộng tên miền cộng cổng khác một trong ba là khác origin JS ở web.vn không được đọc response từ api.khac.vn để chống web độc đọc trộm dữ liệu bạn đang đăng nhập site khác lưu ý chặn là ở trình duyệt không phải server curl và Postman không bị, CORS server chủ động cho phép qua header Access-Control-Allow-Origin web.tin-cay.vn Allow-Methods Allow-Headers Allow-Credentials trình duyệt thấy header khớp origin thì cho JS đọc không thì chặn, preflight OPTIONS hỏi trước với request không đơn giản request đơn giản GET POST form gửi thẳng request không đơn giản PUT DELETE header Authorization JSON trình duyệt gửi OPTIONS preflight hỏi trước có được phép không Access-Control-Max-Age cache kết quả preflight đỡ hỏi lại curl -X OPTIONS -H Origin -H Access-Control-Request-Method POST

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ặc POST với Content-Type là 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ặc Content-Type: application/json. Với loại này trình duyệt gửi một request preflight OPTIONS hỏi trước: "tôi định gọi POST với header Authorization, 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:

Ảnh chụp bảng kết quả chạy thật python cộng curl output thật server chỉ cho phép origin web.tin-cay.vn, một preflight OPTIONS từ origin được phép curl -X OPTIONS -H Origin web.tin-cay.vn trả HTTP 204 No Content Access-Control-Allow-Origin web.tin-cay.vn Access-Control-Allow-Methods GET POST DELETE Access-Control-Allow-Headers Content-Type Authorization Access-Control-Max-Age 600, hai request thật từ origin được phép có Allow-Origin HTTP 200 OK Access-Control-Allow-Origin web.tin-cay.vn, ba origin lạ không có Allow-Origin trình duyệt chặn curl -H Origin ke-la.evil.com trả HTTP 200 OK không có Access-Control-Allow-Origin server vẫn trả body nhưng trình duyệt không cho JS đọc

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:

  1. Preflight OPTIONS từ origin được phép → 204 No Content kèm đủ bộ Access-Control-Allow-Origin/Methods/Headers và Access-Control-Max-Age: 600. Không có body — preflight chỉ để hỏi phép.
  2. 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.
  3. Origin lạ https://ke-la.evil.com → vẫn 200 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à curl thì 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ùng Allow-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ề

  1. 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ận 200 OK và full body — chỉ trình duyệt mới chặn JS đọc.
  2. Request "không đơn giản" phải qua preflight OPTIONS: PUT/DELETE, header Authorization, Content-Type: application/json khiế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.
  3. Bảo mật thật vẫn ở server: CORS không ngăn curl hay 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ù Origin hay dùng * cùng Allow-Credentials.

Nguồn

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.