CORS là lỗi khiến nhiều người mất cả buổi chiều. 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.
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());
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 — trước khi cấu hình CORS của MVC kịp có tiếng nói. Đây là nguyên nhân số một của "tôi đã bật CORS mà vẫn lỗi".
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.
Thử ba mươi giây
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. Ba mươi giây, không cần mở trình duyệt.
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.