CORS là gì và debug lỗi CORS trong production như thế nào?
Hiểu đúng Same-Origin Policy, simple request, preflight, credentials, cookies, CSRF và cách cấu hình CORS an toàn với Spring Boot 3.
CORS là gì và debug lỗi CORS trong production như thế nào?
Câu hỏi
CORS hoạt động như thế nào? Vì sao request gọi được bằng Postman hoặc
curlnhưng frontend trên browser lại bị chặn? Hãy giải thích preflight, credentials, các CORS headers và cách cấu hình/debug an toàn trong Spring Boot 3.
Dành cho level
Interviewer expect bạn phân biệt được origin, Same-Origin Policy và CORS; biết request nào cần preflight; đọc được các header Origin, Access-Control-Allow-Origin, Access-Control-Allow-Methods và Access-Control-Allow-Headers. Bạn cũng cần hiểu CORS là cơ chế do browser enforce, nên Postman gọi thành công không chứng minh cấu hình CORS đúng.
Cốt lõi cần nhớ
- CORS không chặn server nhận request; nó quyết định browser có cho JavaScript đọc response hay không. Với một số request có side effect, request thật vẫn có thể được gửi dù JavaScript không đọc được response. Vì vậy CORS không thay thế authentication, authorization hay CSRF protection.
- Origin = scheme + host + port.
https://app.example.com,http://app.example.comvàhttps://app.example.com:8443là ba origin khác nhau; path không thuộc origin. - Debug theo hai chặng: preflight
OPTIONStrước, actual request sau. Kiểm tra requestOrigin/Access-Control-Request-*, status/redirect và responseAccess-Control-Allow-*; đừng chữa bằngmode: "no-cors"hayAccess-Control-Allow-Origin: *một cách mù quáng.
Câu trả lời mẫu
Khi gặp lỗi CORS, tôi bắt đầu từ Network tab để xác định browser đang chặn ở preflight hay ở response của request thật, thay vì sửa frontend theo cảm tính. Tôi kiểm tra chính xác origin gồm scheme, host và port, rồi đối chiếu
Origin, method, request headers với allowlist phía server. Nếu cóOPTIONS, tôi đảm bảo request này đi qua CDN/gateway và được xử lý trước authentication trong Spring Security, vì preflight theo chuẩn không mang cookie đăng nhập. Với cookie cross-origin, tôi cấu hình đồng bộ cả hai phía: frontend dùngcredentials: "include", server trả một origin cụ thể cùngAccess-Control-Allow-Credentials: true, còn cookie phải phù hợp vớiSameSitevàSecure. Tôi không dùng wildcard khi có credentials và không coi CORS là authorization, vì client ngoài browser vẫn gọi được API. Trong production tôi chỉ để một layer sở hữu CORS policy, thêmVary: Originkhi response thay đổi theo origin, và test từ browser qua đúng domain/CDN thật. Tôi từng gặpcurltrả200nhưng browser fail vì ALB redirectOPTIONSsang login; nhìn đầy đủ hai chặng request đã giúp xác định lỗi nằm ở security chain chứ không phải controller.
Phân tích chi tiết
1. Bắt đầu từ production scenario: curl thành công, browser thất bại
Giả sử frontend được phục vụ tại:
https://app.example.comvà gọi API:
https://api.example.com/ordersBackend vẫn khỏe:
curl -i https://api.example.com/orders
# HTTP/2 200
# Content-Type: application/json
# {"items":[...]}Nhưng browser báo:
Access to fetch at 'https://api.example.com/orders'
from origin 'https://app.example.com' has been blocked by CORS policyHai kết quả này không mâu thuẫn. curl là HTTP client, nó không thực thi Same-Origin Policy của browser. Browser nhận biết JavaScript từ app.example.com đang muốn đọc tài nguyên của api.example.com, nên áp dụng CORS protocol trước khi giao response cho JavaScript.
Mental model đúng là:
JavaScript
│ fetch()
▼
Browser security boundary
│ quyết định có gửi preflight không
│ kiểm tra CORS response headers
▼
Network / CDN / Gateway / BackendCORS error trong console chỉ nói rằng browser không cho JavaScript dùng response. Nó chưa nói backend chết, DNS sai, certificate sai, request bị 401, gateway redirect hay response thiếu header. Vì vậy bước đầu tiên luôn là mở DevTools → Network, không chỉ đọc một dòng console.
2. Origin chính xác là gì?
Một origin gồm đúng ba thành phần:
origin = scheme + host + portSo sánh với https://app.example.com:
| URL | Same origin? | Lý do |
|---|---|---|
https://app.example.com/profile | Có | Path không thuộc origin |
https://app.example.com:443/api | Có | 443 là default port của HTTPS |
http://app.example.com | Không | Khác scheme |
https://api.example.com | Không | Khác host |
https://www.app.example.com | Không | Khác host/subdomain |
https://app.example.com:8443 | Không | Khác port |
Hai hiểu lầm rất phổ biến:
- Cùng parent domain không có nghĩa là cùng origin.
app.example.comvàapi.example.comvẫn cross-origin. - Path không ảnh hưởng origin.
/adminvà/publictrên cùng scheme/host/port là same-origin; muốn phân quyền theo path phải dùng authorization, không dùng CORS như access-control theo user.
Browser thường gửi request header:
Origin: https://app.example.comOrigin không có path và không có dấu / cuối. Vì vậy allowlist https://app.example.com/ có thể không match nếu framework yêu cầu exact origin string.
3. Same-Origin Policy và CORS liên hệ thế nào?
Same-Origin Policy (SOP) là security boundary mặc định của browser: script từ một origin không được tùy ý đọc dữ liệu của origin khác. Nếu không có boundary này, một trang độc hại mở trong browser có thể dùng session đang đăng nhập của bạn để đọc email, tài khoản ngân hàng hoặc dữ liệu nội bộ từ site khác.
CORS là protocol dựa trên HTTP headers để server nói với browser:
"Tôi cho phép JavaScript từ origin X đọc response này
với method/header/credentials theo policy Y."Điểm tinh tế: SOP chủ yếu giới hạn khả năng đọc response từ script, không phải firewall chặn mọi request cross-origin.
<form>từ site A từ lâu đã có thể submitPOSTsang site B.<img>có thể tải ảnh từ site B.- Một số
fetchcross-origin có thể được gửi và server vẫn thực hiện side effect. - Nhưng JavaScript chỉ đọc được response nếu CORS checks pass.
Do đó:
CORS ≠ authentication
CORS ≠ authorization
CORS ≠ CSRF protection
CORS ≠ network firewallAPI phải xác thực token/cookie và phân quyền ở server với mọi client. Không được nghĩ rằng “origin không nằm trong CORS allowlist nên attacker không gọi API được”; attacker có thể dùng curl, mobile app, server riêng hoặc gửi form mà không cần browser đọc response.
4. Hai flow quan trọng: request không preflight và request có preflight
Flow A — request không cần preflight
Frontend:
const response = await fetch("https://api.example.com/public/products");
const products = await response.json();Browser có thể gửi thẳng:
GET /public/products HTTP/1.1
Host: api.example.com
Origin: https://app.example.comBackend trả:
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
{"items":[]}Browser so sánh Origin đã gửi với Access-Control-Allow-Origin nhận về. Nếu hợp lệ, JavaScript được đọc response. Nếu header thiếu hoặc sai, request có thể vẫn nhận 200 trên network nhưng fetch() reject với TypeError: Failed to fetch.
“Simple request” thực chất là gì?
Fetch specification hiện không nhấn mạnh tên gọi này, nhưng tài liệu/interview vẫn thường gọi là simple request. Request tránh preflight khi thỏa toàn bộ nhóm điều kiện chính:
- Method chỉ là
GET,HEADhoặcPOST. - Header do JavaScript tự đặt chỉ thuộc CORS safelist, ví dụ
Accept,Accept-Language,Content-Language, vàContent-Typevới giới hạn. Content-Type, nếu có, chỉ làapplication/x-www-form-urlencoded,multipart/form-datahoặctext/plain.- Không dùng các khả năng đặc biệt như upload listener/stream khiến request ra khỏi safelist.
POST không đồng nghĩa với preflight. Ví dụ form POST dùng application/x-www-form-urlencoded có thể không preflight. Ngược lại, một GET có header Authorization sẽ preflight.
Flow B — request có preflight
Frontend gọi:
const response = await fetch("https://api.example.com/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${accessToken}`,
"X-Request-Id": crypto.randomUUID(),
},
body: JSON.stringify({ productId: "p-123", quantity: 2 }),
});application/json, Authorization và X-Request-Id làm request này cần preflight. Browser tự gửi OPTIONS trước:
OPTIONS /orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type,x-request-idServer phải trả policy phù hợp:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
Access-Control-Max-Age: 600
Vary: OriginBrowser kiểm tra response. Nếu pass, nó mới gửi actual request:
POST /orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer eyJ...
Content-Type: application/json
X-Request-Id: 019...
{"productId":"p-123","quantity":2}Và actual response cũng phải có CORS header:
HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Type: application/json
{"orderId":"ord-456"}Preflight pass nhưng actual response thiếu Access-Control-Allow-Origin vẫn khiến browser chặn JavaScript đọc response.
5. Điều gì kích hoạt preflight?
Thay vì học thuộc một danh sách rời rạc, hãy dùng mental model:
Nếu request vượt ra ngoài những gì một HTML form truyền thống có thể gửi,
browser thường muốn hỏi server trước bằng OPTIONS.Các trigger thường gặp:
| Request | Preflight? | Vì sao |
|---|---|---|
GET không custom header | Thường không | Safelisted |
POST + text/plain | Thường không | Method/content type safelisted |
POST + application/json | Có | JSON không phải safelisted content type |
GET + Authorization | Có | Authorization không thuộc safelist |
PUT, PATCH, DELETE | Có | Method không thuộc safelist |
Bất kỳ method + X-Tenant-Id | Có | Custom request header |
Không nên “tối ưu” bằng cách đổi JSON sang text/plain chỉ để né preflight nếu làm API contract khó hiểu hoặc ảnh hưởng security. Nếu preflight là overhead đáng kể, giải pháp đúng thường là cache preflight hợp lý, dùng same-origin reverse proxy/BFF, hoặc đo xem latency thực sự nằm ở đâu.
6. Bảng các CORS headers cần nhớ
Request headers do browser gửi
| Header | Xuất hiện ở đâu | Ý nghĩa |
|---|---|---|
Origin | Preflight và actual request cross-origin | Origin của page khởi tạo request |
Access-Control-Request-Method | Chỉ preflight | Method của actual request dự kiến |
Access-Control-Request-Headers | Chỉ preflight | Danh sách header của actual request dự kiến |
Frontend không nên tự set các header Origin hoặc Access-Control-Request-*; browser quản lý chúng.
Response headers do server gửi
| Header | Ý nghĩa | Lưu ý |
|---|---|---|
Access-Control-Allow-Origin | Origin được phép đọc response | Một response chỉ có một origin cụ thể hoặc * |
Access-Control-Allow-Methods | Methods được phép | Chủ yếu dùng khi trả preflight |
Access-Control-Allow-Headers | Request headers được phép | Phải cover headers browser hỏi trong preflight |
Access-Control-Allow-Credentials | Cho phép expose response khi request có credentials | Giá trị hợp lệ là true, không phải * |
Access-Control-Expose-Headers | Cho JavaScript đọc thêm response headers | Không liên quan request headers |
Access-Control-Max-Age | Thời gian browser cache kết quả preflight | Browser có internal cap riêng |
Vary: Origin | Báo cache rằng response thay đổi theo origin | Quan trọng với dynamic allowlist/CDN |
Allow-Headers khác Expose-Headers
Đây là lỗi interview phổ biến:
Access-Control-Allow-Headers
→ browser được phép GỬI header nào, ví dụ Authorization, X-Request-Id
Access-Control-Expose-Headers
→ JavaScript được phép ĐỌC thêm response header nào,
ví dụ Location, X-Request-Id, X-RateLimit-RemainingVí dụ API tạo resource trả Location, frontend muốn đọc:
Access-Control-Expose-Headers: Location, X-Request-Idconst location = response.headers.get("Location");Set-Cookie không trở thành header JavaScript đọc được chỉ vì thêm vào Expose-Headers; browser quản lý cookie theo cookie model và không expose Set-Cookie cho frontend code.
7. Credentials: cookie, HTTP authentication và TLS client certificate
Trong Fetch API, credentials kiểm soát việc browser gửi credentials và xử lý cookie response:
await fetch("https://api.example.com/me", {
credentials: "include",
});Với cross-origin request dùng credentials, server cần:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: OriginKhông được dùng:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueBrowser sẽ từ chối wildcard origin khi credentials mode yêu cầu credentials. Server phải trả origin cụ thể đã được validate.
Một flow cookie thành công cần nhiều điều kiện độc lập:
Frontend fetch credentials: "include"
+
Server Access-Control-Allow-Credentials: true
+
Server Access-Control-Allow-Origin: exact allowed origin
+
Cookie Domain/Path phù hợp
+
Cookie Secure nếu cần HTTPS
+
Cookie SameSite phù hợp
+
Browser third-party-cookie policy cho phépCORS đúng không đảm bảo cookie sẽ được gửi. Ví dụ cross-site cookie thường cần:
Set-Cookie: SESSION=abc; Path=/; HttpOnly; Secure; SameSite=NoneNhưng không nên máy móc đổi mọi cookie thành SameSite=None. Nếu frontend và API có thể đặt cùng site hoặc dùng BFF/reverse proxy same-origin, SameSite=Lax/Strict có security posture tốt hơn. Ngoài ra, browser/privacy policy có thể chặn third-party cookies bất kể server đã cấu hình CORS đúng.
Bearer token có phải “credentials” của Fetch không?
Header Authorization: Bearer ... làm request cần preflight, nhưng việc bạn tự đặt header này không giống cookie tự động gắn theo credentials: "include". Dù vậy, API vẫn phải allow Authorization trong preflight và xác thực token ở actual request. Không nên kết luận rằng dùng JWT thì không cần quan tâm CORS.
8. CORS và CSRF: liên quan nhưng không thay thế nhau
CSRF xảy ra khi browser tự động gắn credentials, thường là cookie, vào request do site độc hại khởi tạo. Attacker có thể không đọc được response nhưng vẫn gây side effect như đổi email hoặc chuyển tiền nếu server chỉ dựa vào cookie.
Ví dụ request “simple” có thể được gửi không cần preflight:
<form action="https://bank.example/transfer" method="POST">
<input name="amount" value="1000000" />
<input name="to" value="attacker" />
</form>
<script>document.forms[0].submit()</script>Nếu server nghĩ “CORS không cho evil.example nên an toàn” thì sai: form submission không cần JavaScript đọc response. Server vẫn phải có CSRF defense phù hợp:
SameSitecookie.- CSRF token/synchronizer token hoặc cookie-to-header pattern.
- Kiểm tra
Origin/Referernhư một lớp defense-in-depth cho state-changing request. - Re-authentication/step-up auth cho thao tác đặc biệt nhạy cảm.
- Authorization và business invariant ở server.
Trong Spring Security, không nên copy config .csrf(csrf -> csrf.disable()) chỉ để “fix CORS”. Với stateless API dùng bearer token không tự động gửi bởi browser, CSRF risk profile khác cookie-based session. Quyết định disable/enable CSRF phải dựa trên authentication mechanism, không dựa trên việc frontend đang báo CORS error.
9. Cấu hình CORS đúng trong Spring Boot 3 / Spring Security 6
Một cấu hình tập trung, explicit và phù hợp production:
package com.example.api.config;
import java.time.Duration;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
public class SecurityConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cors = new CorsConfiguration();
// Exact origins lấy từ typed configuration/environment trong code thật.
cors.setAllowedOrigins(List.of(
"https://app.example.com",
"https://admin.example.com"
));
cors.setAllowedMethods(List.of(
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
));
cors.setAllowedHeaders(List.of(
"Authorization", "Content-Type", "X-Request-Id", "X-CSRF-TOKEN"
));
cors.setExposedHeaders(List.of(
"Location", "X-Request-Id", "X-RateLimit-Remaining"
));
cors.setAllowCredentials(true);
// 10 phút: giảm preflight nhưng policy thay đổi vẫn rollout tương đối nhanh.
// Không có con số đúng cho mọi hệ thống; browser còn có internal cap.
cors.setMaxAge(Duration.ofMinutes(10));
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cors);
return source;
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Spring Security dùng CorsConfigurationSource ở trên.
// CORS phải được xử lý trước authentication cho preflight.
.cors(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
// Chỉ quyết định CSRF sau khi phân tích cơ chế auth.
// Không disable CSRF chỉ để chữa lỗi CORS.
return http.build();
}
}Spring Security yêu cầu CORS được xử lý trước security authentication vì preflight chuẩn không mang session cookie như JSESSIONID. Khi security chain chạy trước và yêu cầu authenticated cho OPTIONS, browser nhận 401/403, actual request không bao giờ được gửi.
Đưa origins ra configuration
Không hard-code domain production cùng localhost trong class nếu nhiều environment:
app:
cors:
allowed-origins:
- https://app.example.com
- https://admin.example.comCó thể bind bằng @ConfigurationProperties và validate URL khi startup. Production không nên tự động allow http://localhost:*; local origin chỉ xuất hiện trong development profile.
allowedOrigins và allowedOriginPatterns
allowedOrigins: exact list, dễ audit, ưu tiên khi số domain hữu hạn.allowedOriginPatterns: hữu ích khi thật sự cần pattern port/subdomain, nhưng blast radius lớn hơn.
Không dùng pattern rộng như https://*.example.com nếu bất kỳ subdomain nào có thể do user tạo hoặc có nguy cơ subdomain takeover. Một subdomain bị chiếm sẽ trở thành trusted origin và, nếu credentials được phép, có thể đọc dữ liệu người dùng.
Có nên dùng @CrossOrigin?
@CrossOrigin là API thật của Spring MVC và hữu ích cho demo hoặc policy rất cục bộ:
@CrossOrigin(origins = "https://app.example.com")
@RestController
@RequestMapping("/api/products")
class ProductController {
// ...
}Nhưng trong hệ thống lớn, rải annotation qua controller làm policy khó audit, dễ conflict với global config và dễ quên endpoint mới. Thường nên để một central policy owner; chỉ dùng per-controller override khi có requirement rõ và test riêng.
10. Một layer sở hữu CORS policy
Request production thường đi qua nhiều tầng:
Browser
-> CloudFront/CDN
-> AWS ALB / API Gateway / Ingress
-> Spring Security
-> Spring MVC ControllerNếu cả CDN, ingress và application cùng thêm CORS headers, response có thể thành:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Origin: *Nhiều browser coi multiple values là invalid. Hoặc preflight do gateway trả đúng nhưng error response 500 từ application lại thiếu header; kết quả là frontend chỉ thấy “CORS error” và root cause thật bị che.
Thiết kế nên chỉ rõ:
Policy owner: API Gateway hoặc Application — chọn một
Các layer còn lại: pass-through, không tự ý append/overwrite
Error responses: cũng phải đi qua CORS processing
Redirects: không redirect OPTIONS sang login/trailing slash/domain khác
Cache key: vary theo Origin khi response CORS khác nhauVary: Origin vì sao quan trọng?
Nếu server dynamic echo một origin đã allow:
Access-Control-Allow-Origin: https://tenant-a.example
Vary: OriginCDN/proxy cần biết response cho tenant A khác response cho tenant B. Thiếu Vary: Origin, cache có thể trả header của tenant A cho tenant B, gây lỗi chức năng; trong thiết kế tệ hơn, cache/policy sai có thể tạo data exposure.
Không được đơn giản làm:
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));Đó là blind origin reflection. Chỉ echo sau khi exact/pattern match một allowlist đáng tin cậy, và phải xử lý cả null origin theo policy rõ ràng.
11. Quy trình debug CORS theo flow, không đoán
Bước 1 — Ghi lại tuple của request
Page origin: https://app.example.com
Request URL: https://api.example.com/orders
Method: POST
Request headers: Authorization, Content-Type, X-Request-Id
Credentials: include hay omit?
Expected status: 201Không nói chung chung “domain đã allow”. So exact scheme/host/port.
Bước 2 — Mở Network tab và tìm OPTIONS
Nếu có preflight, kiểm tra:
Request:
Origin
Access-Control-Request-Method
Access-Control-Request-Headers
Response:
status có 2xx không?
có redirect 301/302/307/308 không?
Access-Control-Allow-Origin có exact không?
Allow-Methods có method thật không?
Allow-Headers có đủ không?
Allow-Credentials có cần không?Nếu không thấy actual request, lỗi nằm ở preflight. Nếu actual request có xuất hiện, tiếp tục xem actual status/headers/body.
Bước 3 — Reproduce preflight bằng curl
curl không tự enforce CORS, nhưng ta có thể mô phỏng preflight để inspect raw response:
curl -i -X OPTIONS 'https://api.example.com/api/orders' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type,x-request-id'Expected tối thiểu:
HTTP/2 200
access-control-allow-origin: https://app.example.com
access-control-allow-methods: POST
access-control-allow-headers: authorization,content-type,x-request-idTest actual response với Origin:
curl -i 'https://api.example.com/api/orders/ord-456' \
-H 'Origin: https://app.example.com' \
-H 'Authorization: Bearer redacted'Nếu gọi curl không có Origin, backend có thể không trả CORS headers; đó không phải bằng chứng lỗi.
Bước 4 — Theo trace qua từng hop
Dùng X-Request-Id/OpenTelemetry trace để kiểm tra:
CDN access log
ALB/API Gateway log
Ingress log
Spring Security log
Application traceMột response 403 có thể do WAF chặn OPTIONS; 301 có thể do HTTP→HTTPS; 302 có thể do OAuth login; 404 có thể do route không nhận OPTIONS. Browser gom nhiều trường hợp thành CORS-looking failure.
Bước 5 — Kiểm tra cả error path
Happy path 200 có CORS header nhưng 401, 403, 429, 500 không có header sẽ làm frontend mất status/body thật. CORS processing nên bao phủ error responses để frontend đọc được lỗi hợp lệ, miễn là origin được allow.
Bước 6 — Test từ browser thật
Unit test config chưa đủ. Cần browser/E2E test qua domain thật vì:
- CDN có thể cache/strip header.
- Gateway có thể tự trả preflight.
- TLS/domain/redirect khác localhost.
- Browser cookie policy không xuất hiện trong Postman.
- Service worker hoặc extension có thể ảnh hưởng request.
12. Những lỗi thường gặp và cách định vị
| Triệu chứng | Root cause khả dĩ | Kiểm tra đầu tiên |
|---|---|---|
OPTIONS 401/403 | Security chạy trước CORS | Spring Security chain/gateway auth |
OPTIONS 404/405 | Route/proxy không xử lý OPTIONS | Ingress/API Gateway route |
Preflight pass, POST bị block | Actual response thiếu/sai ACAO | Headers của actual response |
| Wildcard + credentials error | * không hợp lệ với credentials | Allow-Origin và fetch credentials |
| Cookie không được gửi | credentials, SameSite, Secure, third-party policy | DevTools Cookies/Issues |
Frontend không đọc được Location | Thiếu Expose-Headers | Response CORS config |
| Chỉ một tenant/domain lỗi | Exact origin mismatch/cache | Scheme/port/trailing config, Vary |
| Localhost chạy, production lỗi | CDN/gateway/environment allowlist | Test từng hop |
| Chỉ error response hiện CORS | Error path thêm header không nhất quán | Filter order/error handler |
| Browser báo CORS sau redirect | Preflight/actual request bị redirect | Network redirect chain |
Trường hợp Origin: null
Browser có thể gửi Origin: null trong một số sandboxed iframe, local file hoặc opaque origin. Không nên mặc định allow null, đặc biệt với credentials. Hãy coi nó như một origin riêng chỉ allow khi có use case và threat model cụ thể.
Private Network Access
Browser hiện đại có thêm kiểm soát khi public website gọi tài nguyên ở private network/local network. Lỗi có thể nhìn giống CORS và liên quan preflight bổ sung. Đừng giải quyết bằng allow-all trên router/admin endpoint; cần hiểu client context, browser support và tránh expose private management plane cho public origin.
13. Security checklist cho CORS policy
[ ] Exact allowlist theo environment, không lấy Origin rồi echo mù quáng
[ ] Không dùng wildcard origin với credentials
[ ] Chỉ allow methods thật sự cần
[ ] Chỉ allow request headers thật sự cần
[ ] Expose tối thiểu response headers cho frontend
[ ] Localhost chỉ ở dev/test profile
[ ] Review wildcard subdomain và subdomain takeover risk
[ ] CORS xử lý trước authentication cho preflight
[ ] Không disable CSRF chỉ vì lỗi CORS
[ ] Error responses có CORS headers nhất quán
[ ] Dynamic origin response có Vary: Origin
[ ] Một layer duy nhất sở hữu policy
[ ] Preflight không bị redirect
[ ] CDN cache behavior được test với ít nhất 2 origins
[ ] Có log/metric cho denied origins nhưng không log token/cookie
[ ] Contract/E2E tests chạy qua public endpoint thậtCORS allowlist là một phần security configuration, nên thay đổi cần code review và audit trail. Không nên có endpoint admin runtime cho phép bất kỳ user nào thêm origin mà không validate ownership/domain lifecycle.
14. Observability và alerting
Không nên alert chỉ vì có origin bị deny; internet luôn có scan/noise. Nên đo theo service, environment, normalized origin và reason:
cors_preflight_requests_total{service,route,result}
cors_denied_requests_total{service,reason}
http_server_requests_seconds_count{method="OPTIONS",status}
http_server_requests_seconds_bucket{method="OPTIONS"}Không đưa full high-cardinality URL, access token hay cookie vào label/log. Với origin, nếu tenant count rất lớn, log sampled hoặc map sang tenant ID đã biết thay vì tạo Prometheus label không giới hạn.
Alert có ý nghĩa:
groups:
- name: cors
rules:
- alert: CorsPreflightFailureSpike
expr: |
sum(rate(http_server_requests_seconds_count{method="OPTIONS",status=~"4..|5.."}[10m]))
/
clamp_min(sum(rate(http_server_requests_seconds_count{method="OPTIONS"}[10m])), 1)
> 0.05
for: 10m
annotations:
summary: "Tỷ lệ preflight 4xx/5xx vượt 5% trong 10 phút"Ngưỡng 5% chỉ là ví dụ khởi đầu, không phải magic number. Cần baseline traffic thật; endpoint public bị scan có thể có nhiều OPTIONS invalid, nên alert nên kết hợp known-origin traffic hoặc release marker để tránh noise.
15. Test CORS policy
Integration test với Spring MockMvc
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void allowsKnownOriginToCreateOrder() throws Exception {
mockMvc.perform(options("/api/orders")
.header("Origin", "https://app.example.com")
.header("Access-Control-Request-Method", "POST")
.header(
"Access-Control-Request-Headers",
"authorization,content-type,x-request-id"
))
.andExpect(status().isOk())
.andExpect(header().string(
"Access-Control-Allow-Origin",
"https://app.example.com"
));
}
@Test
void doesNotAllowUnknownOrigin() throws Exception {
mockMvc.perform(options("/api/orders")
.header("Origin", "https://evil.example")
.header("Access-Control-Request-Method", "POST"))
.andExpect(status().isForbidden());
}Exact status cho rejected preflight có thể khác theo framework/config, nên contract chính cần assert rằng unknown origin không nhận allow header và actual browser flow bị từ chối. Đừng viết test quá phụ thuộc implementation nếu gateway là policy owner.
Những case tối thiểu
1. Known origin + allowed method/header -> pass
2. Unknown origin -> no allow
3. Known origin + disallowed method -> reject
4. Known origin + disallowed header -> reject
5. Credentialed request -> exact origin + allow-credentials
6. Actual 2xx response -> correct CORS headers
7. Actual 401/403/429/500 -> correct CORS headers cho known origin
8. Hai origin qua CDN -> không lẫn cached Allow-Origin
9. Localhost không được allow ở production
10. OPTIONS không redirect và không yêu cầu login16. Khi nào tránh cross-origin ngay từ architecture?
CORS không xấu; nhưng nhiều hệ thống có thể đơn giản hóa bằng same-origin BFF/reverse proxy:
Browser: https://app.example.com
GET / -> frontend assets
POST /api/orders -> reverse proxy/BFF -> internal order serviceBrowser chỉ thấy một origin nên không cần CORS cho browser-to-BFF. BFF có thể giữ token server-side, dùng secure HttpOnly cookie và giảm việc access token xuất hiện trong JavaScript.
Trade-off:
- Thêm một hop và component cần vận hành.
- BFF phải chống CSRF nếu dùng cookie.
- Không loại bỏ auth/authz giữa BFF và services.
- Mobile/partner APIs vẫn cần contract riêng.
Dùng BFF khi nó giải quyết nhiều vấn đề cùng lúc: token handling, API composition, frontend-specific response và same-origin security model. Không dựng BFF chỉ để che một dòng cấu hình CORS sai.
Bẫy thường gặp
❌ "Cứ thêm Access-Control-Allow-Origin: * là hết lỗi."
→ Tại sao sai: Wildcard không dùng được với credentialed request, mở data public cho mọi website đọc, và không sửa được method/header/security/redirect sai ở preflight.
✅ Đúng hơn: Allow exact origins theo environment, method/header tối thiểu, rồi test cả preflight lẫn actual response.
❌ "Postman gọi được nên backend không có vấn đề."
→ Tại sao sai: Postman và curl không enforce Same-Origin Policy. Browser mới là component gửi/kiểm tra CORS protocol.
✅ Đúng hơn: Reproduce raw preflight với curl, nhưng kết luận cuối cùng bằng Network tab/E2E browser qua public endpoint thật.
❌ "CORS chặn attacker gọi API nên không cần authorization." → Tại sao sai: CORS chỉ là browser read boundary; attacker có thể gọi từ server, CLI hoặc gửi request không cần đọc response. ✅ Đúng hơn: Mọi endpoint phải authenticate, authorize và validate business invariant độc lập với Origin.
❌ "Đặt mode: 'no-cors' để browser bỏ qua CORS."
→ Tại sao sai: no-cors giới hạn request và trả opaque response; JavaScript không đọc được status/body/header như API bình thường. Nó không biến API cross-origin thành readable.
✅ Đúng hơn: Sửa policy phía server/gateway hoặc dùng same-origin proxy/BFF có chủ đích.
❌ "Preflight phải gửi JWT/cookie, nếu không thì trả 401 là đúng." → Tại sao sai: CORS preflight theo chuẩn dùng để hỏi policy và không mang credentials như actual request; reject nó ở auth layer khiến request thật không bao giờ được gửi. ✅ Đúng hơn: Xử lý CORS trước authentication, allow preflight theo policy, rồi authenticate/authorize actual request.
❌ "CORS và CSRF là một; bật CORS đúng thì tắt CSRF được." → Tại sao sai: Cross-origin form/request có thể gây side effect dù attacker không đọc được response. Cookie có thể tự động đi kèm request. ✅ Đúng hơn: Đánh giá CSRF theo cơ chế authentication; dùng SameSite/CSRF token/origin checks phù hợp và không disable để chữa lỗi khác.
❌ "Allow https://*.example.com luôn an toàn vì đều là domain công ty."
→ Tại sao sai: User-generated subdomain, abandoned DNS record hoặc subdomain takeover có thể biến origin không đáng tin thành trusted origin.
✅ Đúng hơn: Exact allowlist nếu có thể; nếu bắt buộc pattern, quản trị vòng đời DNS/subdomain và tách vùng user-controlled khỏi trusted namespace.
❌ "Cả CloudFront, ingress và Spring cùng thêm header cho chắc." → Tại sao sai: Duplicate/conflicting headers, cache behavior và error path không nhất quán khiến lỗi khó debug hơn. ✅ Đúng hơn: Chỉ định một policy owner, các layer khác pass-through, và contract-test qua toàn bộ request path.
Câu hỏi follow-up
1. Vì sao request POST application/json thường có preflight nhưng form POST có thể không?
application/json không thuộc nhóm CORS-safelisted content types, trong khi application/x-www-form-urlencoded, multipart/form-data và text/plain có thể thuộc nhóm request không preflight nếu các điều kiện khác cũng thỏa. Lý do lịch sử là browser đã cho HTML form gửi cross-origin từ trước CORS, nên server vốn phải chống CSRF cho các request dạng form. Không nên đổi API JSON sang text/plain chỉ để né preflight nếu làm contract kém rõ ràng.
2. Vì sao preflight trả 200 nhưng actual request vẫn bị chặn?
Preflight chỉ xác nhận browser được phép gửi actual request với origin/method/headers đó. Actual response vẫn phải trả Access-Control-Allow-Origin phù hợp, và nếu dùng credentials còn cần Access-Control-Allow-Credentials: true. Hãy xem entry của POST/GET trong Network tab, đặc biệt status, redirect và headers của error response.
3. Access-Control-Max-Age nên đặt bao nhiêu?
Không có một con số đúng cho mọi hệ thống; browser cũng áp internal maximum riêng. Giá trị vài phút đến vài chục phút thường cân bằng giữa giảm latency/request volume và khả năng rollout policy nhanh, nhưng phải đo traffic thật. Nếu policy origin thay đổi khẩn cấp, max-age dài làm client giữ quyết định cũ lâu hơn, nên cần cân nhắc security revocation và rollout strategy.
4. Có nên validate Origin như một lớp security không?
Có thể dùng Origin/Referer như defense-in-depth cho CSRF và browser-facing endpoints, nhưng không thay thế authentication/authorization. Non-browser client có thể tự đặt Origin, và một trusted subdomain bị compromise vẫn gửi origin hợp lệ. Với state-changing cookie-authenticated request, origin validation nên đi cùng SameSite và CSRF token theo risk của flow.
5. Tại sao cookie vẫn không đi dù CORS headers đều đúng?
Kiểm tra credentials: "include", Domain, Path, Secure, SameSite, expiry và browser third-party-cookie policy. CORS chỉ quyết định JavaScript có được truy cập response cross-origin; cookie có bộ quy tắc riêng. DevTools Application/Storage, Network Cookies và Issues tab thường chỉ rõ cookie bị block vì lý do nào.
6. Có thể cho nhiều origins trong một Access-Control-Allow-Origin header không?
Không theo dạng danh sách https://a.example, https://b.example. Server phải đọc request Origin, match allowlist, rồi trả đúng một origin cụ thể cho response đó, hoặc * trong use case không credentials phù hợp. Khi response thay đổi theo origin, cần Vary: Origin để intermediary cache đúng.
7. CORS có áp dụng cho service-to-service call trong Kubernetes không?
Không theo nghĩa browser CORS enforcement. Spring service A gọi service B bằng HTTP client không bị Same-Origin Policy, dù HTTP headers vẫn có thể tồn tại. Service-to-service security phải dùng network policy, mTLS, workload identity, authentication và authorization; đừng thêm CORS để giải quyết bài toán east-west traffic.
8. Khi frontend có hàng nghìn custom domains của tenant thì quản lý CORS thế nào?
Không nên nhét hàng nghìn domain hard-code vào annotation. Xây registry có ownership verification, lifecycle/audit, cache allowlist có TTL hợp lý và exact-match origin; không query database chậm trên mọi request nếu có thể cache an toàn. Cần test revoke domain, tránh blind reflection, thêm Vary: Origin, và cân nhắc same-origin custom-domain gateway nếu credential/cookie model quá phức tạp.
Xem thêm
- Page 60ms ở Singapore nhưng 6s ở Mỹ — xử lý như thế nào? — request flow qua DNS, CDN, TLS và origin server.
- Scale từ 1,000 lên 50,000 users — cách tổ chức load balancer, stateless application và observability ở production.
Merge 1 triệu bản ghi vào bảng 1 tỷ rows
Chiến lược upsert quy mô lớn: staging table pattern, batch merge, index management, lock contention — production trade-offs khi merge file vào billion-row table.
Nhiều browser tabs nhưng chỉ một WebSocket: dùng SharedWorker thế nào?
Thiết kế một SharedWorker sở hữu WebSocket dùng chung cho nhiều tab: routing subscription, authentication, reconnect, backpressure, lifecycle và fallback an toàn.