Hình dung CORS như một người quản gia thận trọng, nhưng là quản gia của chính trình duyệt bạn, không phải của máy chủ. Anh ta sẵn sàng mang lá thư (request) tới bất cứ máy chủ nào, để máy chủ mở ra xử lý xong xuôi. Nhưng khi cầm thư hồi âm về, anh ta chỉ đọc nó cho JavaScript của trang này nghe nếu máy chủ ghi rõ một dòng: "trang từ cho-phep.com được phép đọc". Không có dòng đó, anh ta vẫn đã giao thư đi rồi — việc đã xảy ra — chỉ là không đọc hồi âm cho bạn. Hiểu đúng người quản gia này là gì thì gỡ được hầu hết cả buổi chiều mà CORS hay ngốn mất. CORS là lỗi khiến nhiều người mất cả buổi chiều, và phần lớn thời gian đó mất vì hiểu sai nó là gì.
Nó là quy tắc của trình duyệt
CORS không bảo vệ máy chủ. curl, Postman, một dịch vụ khác gọi tới — không cái nào bị CORS chặn. Chúng là người đưa thư không có quản gia.
Nó là cơ chế để trình duyệt quyết định xem JavaScript ở trang A có được đọc phản hồi từ máy chủ B hay không. Mặc định là không, và máy chủ B phải nói rõ là được.
Hệ quả thực tế đầu tiên: thấy lỗi CORS trong console không có nghĩa request thất bại. Request đã đi, máy chủ đã xử lý, dữ liệu đã ghi. Trình duyệt chỉ từ chối cho JavaScript đọc kết quả. Với GET thì vô hại; với POST thì việc đã xảy ra rồi.
Preflight
Với request "không đơn giản" — có Content-Type: application/json, hoặc method PUT/DELETE, hoặc header tuỳ biến — trình duyệt gửi trước một OPTIONS:
OPTIONS /cors
Origin: https://cho-phep.com
Access-Control-Request-Method: GET
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: https://cho-phep.com
Máy chủ trả lời "được", trình duyệt mới gửi request thật. Nghĩa là hai lượt đi về cho mỗi lời gọi — lý do có maxAge, sẽ nói ở dưới.
Ba header Vary là quan trọng: chúng nói với bộ đệm rằng phản hồi khác nhau theo origin. Thiếu chúng, một proxy có thể đưa phản hồi dành cho origin A cho origin B.
Origin sai: 403, không phải "thiếu header"
Origin: https://ke-la.com -> HTTP/1.1 403
endpoint không cấu hình CORS -> HTTP/1.1 403
Đây là chỗ tôi vào với kỳ vọng sai. Tôi tưởng Spring trả 200 mà không kèm Access-Control-Allow-Origin, rồi để trình duyệt tự chặn. Thực tế nó trả thẳng 403 Invalid CORS request.
Điều này thay đổi cách gỡ lỗi: nếu preflight của bạn ra 403, đó là Spring từ chối, và bạn có câu trả lời ngay mà không cần mở tab Network. Kiểm bằng một dòng:
curl -si -X OPTIONS https://api.cua-ban.com/duong-dan \
-H "Origin: https://trang-cua-ban.com" -H "Access-Control-Request-Method: POST" | head -1
Ba cách cấu hình
Trên một endpoint:
@CrossOrigin(origins = "https://cho-phep.com")
@GetMapping("/cors")
String cors() { return "ok"; }
Gọn, nhưng rải rác khắp mã và dễ sót.
Toàn cục qua WebMvcConfigurer — đây là cách tôi khuyên:
@Override
public void addCorsMappings(CorsRegistry r) {
r.addMapping("/api/**")
.allowedOrigins("https://trang-cua-ban.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.exposedHeaders("X-Tong-So")
.allowCredentials(true)
.maxAge(3600);
}
Qua CorsConfigurationSource — bắt buộc khi có Spring Security:
@Bean
CorsConfigurationSource nguonCors() {
var c = new CorsConfiguration();
c.setAllowedOriginPatterns(List.of("https://*.cua-ban.com"));
c.setAllowedMethods(List.of("GET", "POST"));
c.setAllowCredentials(true);
var s = new UrlBasedCorsConfigurationSource();
s.registerCorsConfiguration("/api/**", c);
return s;
}
// và trong SecurityFilterChain:
http.cors(Customizer.withDefaults());
Và đây là cái bẫy số một của "tôi đã bật CORS mà vẫn lỗi": có Spring Security mà chỉ cấu hình addCorsMappings là không đủ. Chuỗi lọc của Security chạy trước DispatcherServlet, nên nó chặn preflight OPTIONS vì request đó không mang thông tin xác thực — chặn trước khi cấu hình CORS của MVC kịp có tiếng nói. Phải khai qua CorsConfigurationSource và bật http.cors(...) để người quản gia được hỏi ý kiến trước khi người gác cổng kịp đuổi khách.
allowCredentials không đi với dấu sao
.allowedOrigins("*").allowCredentials(true) // NÉM NGOẠI LỆ lúc khởi động
Đặc tả cấm điều này, và đúng: cho phép mọi trang web gửi kèm cookie của người dùng tới API của bạn là mở cửa cho CSRF ở quy mô toàn Internet.
Spring bắt lỗi này lúc khởi động chứ không âm thầm bỏ qua — đáng khen.
Cần cả hai thì liệt kê origin cụ thể, hoặc dùng allowedOriginPatterns cho mẫu có ký tự đại diện:
.allowedOriginPatterns("https://*.cua-ban.com").allowCredentials(true)
allowedOriginPatterns là bổ sung của Spring, khớp mẫu rồi phản chiếu lại đúng origin trong header — nên không vi phạm đặc tả.
maxAge
.maxAge(3600)
Trình duyệt nhớ kết quả preflight trong một giờ, nên chỉ tốn thêm lượt OPTIONS một lần thay vì mỗi lời gọi. Với ứng dụng gọi API liên tục, đây là thay đổi một dòng cắt được nửa số request.
Chú ý: Chrome có trần riêng là 2 giờ, đặt cao hơn cũng không tác dụng.
exposedHeaders
Mặc định JavaScript chỉ đọc được vài header cơ bản. Header tuỳ biến của bạn — X-Tong-So, X-Trang-Tiep — không đọc được trừ khi liệt kê vào exposedHeaders.
Triệu chứng rất khó chịu: bạn thấy header trong tab Network của DevTools, nhưng response.headers.get('X-Tong-So') trả về null. Không có lỗi, không có cảnh báo.
Đây là lý do phân trang hay được đặt trong thân JSON thay vì header.
Đừng dùng CORS làm bảo mật
allowedOrigins không là cơ chế kiểm soát truy cập. Header Origin do trình duyệt đặt và trình duyệt không cho JavaScript sửa — nhưng bất cứ client nào không phải trình duyệt đều đặt tuỳ ý:
curl -H "Origin: https://cho-phep.com" https://api.cua-ban.com/du-lieu
Xác thực phải là token hoặc session. CORS chỉ quyết định trang nào trong trình duyệt được đọc kết quả.
Và trong sản xuất, đừng để allowedOrigins("*") cho API có dữ liệu thật — không phải vì nó phá bảo mật, mà vì nó xoá đi lớp phòng thủ mà trình duyệt vốn cho bạn miễn phí.
Cách khác: đừng có CORS
Nếu frontend và API cùng tên miền, không có CORS. Cấu hình nginx trỏ /api/** sang backend và / sang tệp tĩnh là xong.
Blog này chạy theo cách đó. Ít cấu hình hơn, ít lượt đi về hơn, và cookie hoạt động không cần SameSite=None.
Muốn biết preflight của mình đang bị từ chối ở đâu, chạy một dòng — không cần mở trình duyệt:
curl -si -X OPTIONS https://api.cua-ban.com/duong-dan \
-H "Origin: https://trang-cua-ban.com" \
-H "Access-Control-Request-Method: POST" | grep -i "^HTTP\|access-control"
Ra 403 là Spring từ chối origin đó. Ra 200 nhưng không có dòng access-control-allow-origin là cấu hình đã khớp URL nhưng chưa khớp origin.
Mẫu số chung
CORS gây rối không phải vì nó phức tạp, mà vì nó là thứ kiểm soát nằm ở phía client (trình duyệt), không phải phía máy chủ — và đó là điều ít người nắm. Vì nó là một giao thức của nền web, mọi backend đều làm đúng một việc giống hệt nhau: đặt vài header Access-Control-*. Express có middleware cors, Django có django-cors-headers, Rails có rack-cors, ASP.NET có AddCors — tên khác nhau, nhưng bên dưới chỉ là cùng mấy cái header, vì chẳng có gì riêng của framework cả.
Hai sự thật phổ quát đáng mang theo. Một: kiểm soát ở phía client không bao giờ là bảo mật. CORS ngồi chung rổ với nút bấm bị vô hiệu hoá, trường ẩn trong form, và validation phía trình duyệt — tất cả đều là tiện ích mà bất kỳ client không phải trình duyệt đều phớt lờ. Người quản gia chỉ quản việc đọc thư của trang này; ai không thuê quản gia thì đọc thoải mái. Nên xác thực luôn phải là token hoặc session kiểm ở máy chủ. Hai: triệu chứng nói dối — một lỗi CORS đỏ lòm trong console không có nghĩa request chưa xảy ra; với một POST, tác dụng phụ đã chạy rồi, đúng bài học "triệu chứng không đo mức nghiêm trọng" của chặng bảo mật. Mô hình tư duy thành thật nhất: trình duyệt đang bảo vệ người dùng của nó khỏi việc bị trang khác đọc trộm dữ liệu xuyên site — nó không hề bảo vệ máy chủ của bạn, và bạn đừng nhờ nó làm việc đó.
Ngày mai: tải tệp lên — và vì sao vượt giới hạn kích thước trả về 413 với thân rỗng.