2.13 — Ví dụ thực tế nhanh
Lỗi CORS là thứ mọi lập trình viên backend đều gặp, và cũng là thứ bị hiểu sai nhiều nhất. Hiểu lầm phổ biến nhất: nghĩ rằng "server chặn request" — thực tế server đã nhận và đã xử lý request, chỉ là trình duyệt từ chối đưa phản hồi cho JavaScript vì thiếu một header. Hiểu lầm đó dẫn tới việc tìm sai chỗ hàng giờ. Bài này chỉ cách đọc thông điệp lỗi để biết chính xác thiếu gì, giải thích preflight — lý do khiến "GET thì được mà POST thì lỗi" — và ba cấu hình sai hay gặp, trong đó có một cấu hình trông như đã bật CORS nhưng thực ra không hoạt động.
Mục tiêu bài học
Sau bài này bạn có thể:
- Đọc thông điệp lỗi CORS và biết chính xác thiếu gì.
- Giải thích preflight request và khi nào nó xảy ra.
- Cấu hình CORS đúng trong ASP.NET Core.
- Tránh ba cấu hình sai thường gặp.
Nội dung bài học
2.13.1 — Thông điệp lỗi
Access to fetch at 'https://api.company.com/leads'
from origin 'https://crm.company.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Đọc từng phần:
| Phần | Nghĩa |
|---|---|
from origin 'https://crm.company.com' | Trang web đang gọi |
at 'https://api.company.com/leads' | Đích đến |
No 'Access-Control-Allow-Origin' header | Thiếu chính xác header này |
Thông điệp nói thẳng thiếu gì. Ba biến thể khác bạn sẽ gặp:
... does not have HTTP ok status.
-> Server trả về lỗi cho request preflight (OPTIONS)
... The value of the 'Access-Control-Allow-Origin' header is not equal to
the supplied origin.
-> Có header nhưng SAI giá trị origin
... Request header field authorization is not allowed by
Access-Control-Allow-Headers.
-> Thiếu khai báo header 'authorization' được phép
Mỗi biến thể chỉ ra một chỗ sửa khác nhau. Đọc kỹ tiết kiệm rất nhiều thời gian (CORS trên MDN).
2.13.2 — Hiểu lầm lớn nhất
Hiểu SAI: "Server từ chối request của tôi."
Thực tế: Server ĐÃ NHẬN request, ĐÃ XỬ LÝ, ĐÃ TRẢ VỀ phản hồi.
TRÌNH DUYỆT từ chối đưa phản hồi đó cho JavaScript.
Bằng chứng: chạy cùng request bằng curl sẽ thành công.
curl -i https://api.company.com/leads -H "Origin: https://crm.company.com"
# 200 OK — dữ liệu trả về bình thường
curl không phải trình duyệt nên không thực thi chính sách CORS.
Hệ quả quan trọng về bảo mật: CORS không bảo vệ API của bạn. Nó bảo vệ người dùng khỏi việc một trang web độc hại đọc dữ liệu từ một trang khác mà họ đang đăng nhập. Ai cũng gọi API của bạn được bằng curl hoặc Postman — nên xác thực và phân quyền vẫn là bắt buộc (bài 2.8).
2.13.3 — Preflight: vì sao GET được mà POST lỗi
Với một số request, trình duyệt gửi trước một request OPTIONS để hỏi xem có được phép không:
1. Trinh duyet -> OPTIONS /api/leads
Origin: https://crm.company.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
2. Server -> 204 No Content
Access-Control-Allow-Origin: https://crm.company.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 3600
3. Trình duyệt -> POST /api/leads (giờ mới gửi request thật)
Khi nào có preflight: khi request dùng method khác GET/HEAD/POST, hoặc có header tuỳ chỉnh (như Authorization), hoặc Content-Type là application/json.
Điều này giải thích triệu chứng rất hay gặp: GET đơn giản thì chạy, nhưng POST với JSON thì lỗi — vì cái sau kích hoạt preflight còn cái trước thì không.
Access-Control-Max-Age cho trình duyệt nhớ kết quả preflight, tránh gửi OPTIONS trước mỗi request. Thiếu nó nghĩa là số request tăng gấp đôi.
2.13.4 — Cấu hình đúng trong ASP.NET Core
builder.Services.AddCors(options =>
{
options.AddPolicy("CrmFrontend", policy =>
{
policy.WithOrigins(
"https://crm.company.com",
"http://localhost:5173") // môi trường phát triển
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials() // nếu dùng cookie
.SetPreflightMaxAge(TimeSpan.FromHours(1));
});
});
var app = builder.Build();
app.UseCors("CrmFrontend"); // PHẢI trước UseAuthentication và MapControllers
2.13.5 — Ba cấu hình sai thường gặp
Sai 1 — UseCors đặt sai vị trí.
// SAI — CORS chạy SAU khi request đã bị từ chối vì chưa xác thực
app.UseAuthentication();
app.UseAuthorization();
app.UseCors("CrmFrontend"); // qua muon
app.MapControllers();
Middleware chạy theo thứ tự đăng ký. Request OPTIONS của preflight không mang token, nên nếu UseAuthentication chạy trước, nó bị từ chối với 401 — và phản hồi 401 đó không có header CORS, nên trình duyệt báo lỗi CORS thay vì lỗi xác thực. Đây là cấu hình sai gây nhầm lẫn nhiều nhất (bài 8.3).
Sai 2 — AllowAnyOrigin cùng AllowCredentials.
// KHÔNG HOẠT ĐỘNG — đặc tả cấm tổ hợp này
policy.AllowAnyOrigin().AllowCredentials();
Đặc tả CORS cấm dùng * cho origin khi có credentials, vì như vậy bất kỳ trang web nào cũng đọc được dữ liệu của người dùng đang đăng nhập. ASP.NET Core sẽ ném exception lúc khởi động.
Phải liệt kê origin cụ thể:
policy.WithOrigins("https://crm.company.com").AllowCredentials();
Sai 3 — AllowAnyOrigin trên production.
// SAI tren production
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
Nó "sửa được lỗi" ngay nên rất hấp dẫn, nhưng nó cho mọi trang web gọi API của bạn từ trình duyệt của người dùng. Với API công khai không có xác thực thì chấp nhận được; với API nội bộ thì không.
Cách đúng: đọc danh sách origin từ cấu hình theo môi trường.
policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>()!)
2.13.6 — Tự kiểm tra trong vài phút
1. Xem preflight có thành công không:
curl -i -X OPTIONS https://api.company.com/leads \
-H "Origin: https://crm.company.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"
Phải trả về 204 hoặc 200 kèm các header Access-Control-Allow-*. Nếu trả về 401 hoặc 404, vấn đề nằm ở thứ tự middleware hoặc route.
2. Kiểm tra thứ tự middleware:
grep -n "UseCors\|UseAuthentication\|UseAuthorization\|MapControllers" Program.cs
UseCors phải đứng trước cả ba cái còn lại.
3. Kiểm tra origin khớp chính xác:
https://crm.company.com va https://crm.company.com/ -> KHAC NHAU
http://localhost:5173 va http://127.0.0.1:5173 -> KHAC NHAU
So khớp origin là chính xác từng ký tự, không có dấu / ở cuối, và localhost khác 127.0.0.1 dù trỏ cùng một chỗ.
2.13.7 — Rà lại code của bạn
Danh sách rà soát CORS
- •UseCors đặt trước UseAuthentication, UseAuthorization và MapControllers.
- •Không dùng AllowAnyOrigin trên production cho API có xác thực.
- •Origin liệt kê cụ thể và đọc từ cấu hình theo môi trường.
- •Origin viết chính xác, không thừa dấu gạch chéo cuối.
- •Đã đặt SetPreflightMaxAge để giảm số request OPTIONS.
- •Đã thử request OPTIONS bằng curl và xác nhận trả về header đúng.
- •Hiểu rằng CORS không bảo vệ API, nên vẫn có xác thực và phân quyền.
Bài tập áp dụng
Bài 1 — Tái hiện lỗi
Dựng một API ASP.NET Core không bật CORS và gọi nó từ một trang HTML ở origin khác. Đọc thông điệp lỗi trong console.
Tiêu chí hoàn thành: bạn thấy lỗi trong console trình duyệt nhưng cũng thấy máy chủ trả về 200 — và giải thích được vì sao cả hai điều đó cùng đúng.
Gợi ý và lời giải — Bài 1
Gợi ý. Dùng curl với header Origin để xem máy chủ thật sự trả về gì. Kết quả sẽ khác với điều bạn thấy trong trình duyệt.
Lời giải — API có CORS cho một origin duy nhất:
builder.Services.AddCors(o => o.AddPolicy("web", p => p
.WithOrigins("http://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod()));
var app = builder.Build();
app.UseCors("web");
Gọi từ origin ĐƯỢC PHÉP:
curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" \
http://127.0.0.1:5288/api/leads/8842
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Gọi từ origin KHÔNG được phép:
curl -s -D - -o /dev/null -H "Origin: http://evil.example" \
http://127.0.0.1:5288/api/leads/8842
HTTP/1.1 200 OK
Hai phản hồi này là toàn bộ bài học của bài tập.
Origin không được phép -> máy chủ VẪN trả 200
-> máy chủ VẪN gửi đầy đủ body
-> chỉ THIẾU header Access-Control-Allow-Origin
Trong khi đó, trình duyệt hiện:
Access to fetch at 'http://127.0.0.1:5288/api/leads/8842' from origin
'http://evil.example' has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
header is present on the requested resource.
Vì sao cả hai cùng đúng:
Máy chủ KHÔNG chặn gì cả. Nó xử lý request và trả kết quả bình thường.
TRÌNH DUYỆT nhận phản hồi, không thấy header cho phép,
và từ chối đưa dữ liệu đó cho JavaScript của trang.
-> dữ liệu ĐÃ đi qua mạng
-> chỉ là JavaScript không đọc được nó
Đây chính là hiểu lầm ở mục 2.13.2, và nó dẫn tới ba hệ quả thực tế:
1. CORS không phải một cơ chế bảo mật cho máy chủ.
curl, Postman, một script Python, một backend khác
-> KHÔNG có khái niệm origin -> CORS không ảnh hưởng gì
Muốn bảo vệ dữ liệu: cần XÁC THỰC và PHÂN QUYỀN.
CORS chỉ bảo vệ NGƯỜI DÙNG khỏi việc một trang web lạ
đọc dữ liệu của họ bằng phiên đăng nhập của chính họ.
2. Nếu request đã gây tác dụng phụ, tác dụng đó ĐÃ xảy ra.
POST /api/leads/8842/xoa từ origin lạ
-> preflight thất bại -> trình duyệt KHÔNG gửi request thật
-> an toàn
Nhưng với một request ĐƠN GIẢN (xem bài 2 dưới đây):
-> không có preflight -> request THẬT được gửi
-> máy chủ xử lý -> dữ liệu bị đổi
-> trình duyệt chỉ chặn việc ĐỌC phản hồi
Đây là lý do CSRF là một vấn đề khác với CORS, và cần biện pháp riêng.
3. Gỡ lỗi CORS phải nhìn vào HEADER, không nhìn vào mã trạng thái.
# Câu hỏi đúng: phản hồi có header cho phép không?
curl -sI -H "Origin: https://crm.vn" https://api.crm.vn/api/leads \
| grep -i access-control
Không có dòng nào -> máy chủ không cho phép origin đó
Có dòng Access-Control-Allow-Origin -> vấn đề nằm ở chỗ khác
Và một điểm hay gây nhầm trong cấu hình:
// SAI — dấu gạch chéo ở cuối làm origin không khớp
.WithOrigins("https://crm.vn/")
// ĐÚNG — origin gồm giao thức + tên miền + cổng, KHÔNG có đường dẫn
.WithOrigins("https://crm.vn")
Origin là một chuỗi so khớp CHÍNH XÁC:
https://crm.vn khác https://www.crm.vn
https://crm.vn khác http://crm.vn
https://crm.vn khác https://crm.vn:8443
Ba dòng này giải thích phần lớn trường hợp "tôi đã cấu hình rồi mà vẫn lỗi".
Bài 2 — Quan sát preflight
Gọi GET rồi gọi POST với JSON, xem tab Network và đếm số request thực tế được gửi.
Tiêu chí hoàn thành: bạn đếm được một request cho GET và hai cho POST JSON, và nêu được chính xác điều gì kích hoạt preflight.
Gợi ý và lời giải — Bài 2
Gợi ý. Không phải phương thức quyết định, mà là tổ hợp phương thức, header và kiểu nội dung.
Lời giải — gọi preflight bằng curl:
curl -s -D - -o /dev/null -X OPTIONS \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type" \
http://127.0.0.1:5288/api/leads
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type
Rồi request thật:
curl -s -D - -o /dev/null -X POST \
-H "Origin: http://localhost:3000" \
-H "Content-Type: application/json" \
-d '{"hoTen":"Nguyễn Văn A"}' \
http://127.0.0.1:5288/api/leads
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Trong tab Network, hai dòng đó hiện ra như sau:
Name Method Status Type
----------- ------- ------ --------
leads OPTIONS 204 preflight
leads POST 200 fetch
Còn GET đơn giản chỉ có một dòng:
leads/8842 GET 200 fetch
Điều gì kích hoạt preflight — một request được coi là "đơn giản" khi thoả CẢ BA:
| Điều kiện | Giá trị được phép |
|---|---|
| Phương thức | GET, HEAD, POST |
| Header tự đặt | Chỉ vài header cơ bản (Accept, Accept-Language, Content-Language, Content-Type) |
Content-Type | text/plain, multipart/form-data, application/x-www-form-urlencoded |
Thiếu MỘT trong ba -> preflight.
Đây là lý do GET được mà POST JSON thì lỗi:
GET không header đặc biệt -> đơn giản -> không preflight
POST với Content-Type: text/plain -> đơn giản -> không preflight
POST với Content-Type: application/json -> KHÔNG đơn giản -> preflight
Chính application/json kích hoạt preflight, không phải POST.
Và một trường hợp hay gặp không kém:
// GET, nhưng CÓ preflight — vì có header tự đặt
fetch('/api/leads', {
headers: { 'Authorization': 'Bearer ...' } // header không nằm trong danh sách cơ bản
});
-> mọi lời gọi API có Bearer token đều kích hoạt preflight
-> tức gần như MỌI lời gọi trong một ứng dụng có đăng nhập
Cái giá của preflight, và cách giảm:
Mỗi lời gọi API = HAI vòng khứ hồi thay vì một
Với độ trễ mạng 50 ms -> thêm 50 ms cho mỗi lời gọi
Một trang gọi 12 API -> thêm 600 ms
builder.Services.AddCors(o => o.AddPolicy("web", p => p
.WithOrigins("https://crm.vn")
.AllowAnyHeader()
.AllowAnyMethod()
.SetPreflightMaxAge(TimeSpan.FromHours(24)))); // <- quan trọng
Access-Control-Max-Age: 86400
-> trình duyệt lưu kết quả preflight 24 giờ
-> chỉ lời gọi ĐẦU TIÊN tới mỗi endpoint phải preflight
Lưu ý: trình duyệt có trần riêng cho giá trị này.
Chrome giới hạn ở 2 giờ, Firefox ở 24 giờ.
-> đặt 24 giờ vẫn đúng, chỉ là Chrome sẽ dùng 2 giờ.
Và cách loại bỏ preflight hoàn toàn: đặt API cùng origin với web.
Trước: web ở https://crm.vn, API ở https://api.crm.vn
-> khác origin -> CORS -> preflight
Sau: web ở https://crm.vn, API ở https://crm.vn/api
-> CÙNG origin -> không có CORS, không có preflight
-> và cookie gửi tự nhiên, không cần cấu hình gì thêm
Cách này thường được dựng bằng một reverse proxy định tuyến /api sang backend. Nó bỏ được cả một lớp cấu hình — nên nếu kiến trúc cho phép, đây là lựa chọn đơn giản hơn hẳn việc cấu hình CORS cho đúng.
Bài 3 — Thử sai thứ tự
Đặt UseCors sau UseAuthentication và quan sát thông điệp lỗi thay đổi thế nào.
Tiêu chí hoàn thành: bạn thấy lỗi đổi từ một thông điệp CORS rõ ràng sang một thông điệp gây hiểu nhầm, và biết thứ tự đúng của các middleware liên quan.
Gợi ý và lời giải — Bài 3
Gợi ý. Preflight là một request OPTIONS không có token. Nghĩ xem điều gì xảy ra nếu middleware xác thực chạy trước.
Lời giải — thứ tự sai:
var app = builder.Build();
app.UseAuthentication(); // <- chạy TRƯỚC
app.UseAuthorization();
app.UseCors("web"); // <- quá muộn
app.MapControllers();
Preflight OPTIONS /api/leads (không có header Authorization)
-> UseAuthorization thấy endpoint yêu cầu đăng nhập
-> trả 401 NGAY, không đi tiếp
-> UseCors không bao giờ chạy -> phản hồi 401 KHÔNG có header CORS
Trình duyệt hiện:
Access to fetch at 'https://api.crm.vn/api/leads' from origin 'https://crm.vn'
has been blocked by CORS policy: Response to preflight request doesn't pass
access control check: No 'Access-Control-Allow-Origin' header is present.
Và đây là chỗ gây hiểu nhầm:
Thông điệp nói: "lỗi CORS"
Nguyên nhân thật: middleware xác thực chặn preflight
-> người sửa đi kiểm tra cấu hình CORS, thấy nó ĐÚNG
-> mất hàng giờ tìm ở nhầm chỗ
Manh mối phân biệt: nhìn MÃ TRẠNG THÁI của request OPTIONS trong tab Network
204 hoặc 200 + thiếu header -> cấu hình CORS sai
401 hoặc 403 -> middleware chặn trước khi CORS chạy
Thứ tự đúng:
var app = builder.Build();
app.UseRouting();
app.UseCors("web"); // <- TRƯỚC xác thực
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Preflight OPTIONS -> UseCors nhận ra đây là preflight
-> trả 204 kèm header CORS NGAY, kết thúc ống dẫn
-> không bao giờ tới UseAuthentication
Kiểm chứng:
curl -s -o /dev/null -w "%{http_code}\n" -X OPTIONS \
-H "Origin: https://crm.vn" -H "Access-Control-Request-Method: POST" \
https://api.crm.vn/api/leads
204 <- đúng
401 <- UseCors đang nằm sai chỗ
Thứ tự đầy đủ của các middleware hay dùng, và lý do từng vị trí:
app.UseExceptionHandler("/error"); // ngoài cùng: bắt mọi lỗi của các tầng trong
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles(); // tệp tĩnh: trả sớm, không qua xác thực
app.UseRouting(); // xác định endpoint -> các tầng sau cần biết
app.UseCors(); // TRƯỚC xác thực, SAU routing
app.UseAuthentication(); // xác định "anh là ai"
app.UseAuthorization(); // xác định "anh được làm gì"
app.UseRateLimiter(); // sau xác thực: giới hạn theo người dùng
app.MapControllers();
Ba quy tắc đứng sau thứ tự này:
1. UseCors phải nằm giữa UseRouting và UseAuthentication.
Sau UseRouting: vì chính sách CORS có thể gắn theo endpoint
Trước UseAuthentication: vì preflight không mang token
2. UseExceptionHandler nằm ngoài cùng.
Nó chỉ bắt được ngoại lệ của những tầng NẰM TRONG nó.
Đặt nó ở giữa -> lỗi của các tầng trước không được xử lý
3. UseRateLimiter sau UseAuthentication.
Trước xác thực: chỉ giới hạn được theo địa chỉ IP
Sau xác thực: giới hạn được theo NGƯỜI DÙNG hoặc theo tenant
Và một cách kiểm chứng thứ tự mà không phải nhớ: viết test.
[Fact]
public async Task Preflight_khong_bi_chan_boi_xac_thuc()
{
var req = new HttpRequestMessage(HttpMethod.Options, "/api/leads");
req.Headers.Add("Origin", "https://crm.vn");
req.Headers.Add("Access-Control-Request-Method", "POST");
var res = await _client.SendAsync(req);
res.StatusCode.Should().Be(HttpStatusCode.NoContent,
"preflight không mang token, nên nó phải được UseCors trả lời trước khi tới UseAuthentication");
res.Headers.Should().ContainKey("Access-Control-Allow-Origin");
}
Test này chạy trong vài chục mili giây và bắt được đúng loại hồi quy khó chịu nhất: ai đó sắp xếp lại Program.cs cho "gọn hơn", và ứng dụng web ngừng gọi được API — trong khi mọi test API bằng curl vẫn xanh, vì curl không gửi preflight.
Tự kiểm tra
Frequently asked questions
Hiểu lầm lớn nhất về CORS là gì?
Nghĩ rằng server chặn request. Thực tế server đã nhận, đã xử lý và đã trả về phản hồi, chỉ là trình duyệt từ chối đưa phản hồi đó cho JavaScript vì thiếu header. Chạy cùng request bằng curl sẽ thành công.
CORS bảo vệ ai?
Nó bảo vệ người dùng khỏi việc một trang web độc hại đọc dữ liệu từ một trang khác mà họ đang đăng nhập. Nó không bảo vệ API, vì ai cũng gọi API được bằng curl hay Postman. Xác thực và phân quyền vẫn là bắt buộc.
Khi nào trình duyệt gửi preflight?
Khi request dùng method khác GET, HEAD hoặc POST, hoặc có header tuỳ chỉnh như Authorization, hoặc Content-Type là application/json. Đó là lý do GET đơn giản thì chạy mà POST với JSON thì lỗi.
Vì sao UseCors phải đứng trước UseAuthentication?
Vì request OPTIONS của preflight không mang token. Nếu UseAuthentication chạy trước, nó từ chối với 401, và phản hồi 401 đó không có header CORS nên trình duyệt báo lỗi CORS thay vì lỗi xác thực.
Vì sao không dùng được AllowAnyOrigin cùng AllowCredentials?
Vì đặc tả CORS cấm tổ hợp đó: nếu cho phép mọi origin kèm credentials thì bất kỳ trang web nào cũng đọc được dữ liệu của người dùng đang đăng nhập. ASP.NET Core ném exception ngay lúc khởi động.
Vì sao cần SetPreflightMaxAge?
Để trình duyệt nhớ kết quả preflight trong một khoảng thời gian, tránh gửi request OPTIONS trước mỗi lời gọi. Thiếu nó thì số request thực tế tăng gấp đôi.
Kết luận
Ba điều đáng nhớ nhất:
- CORS là chính sách của trình duyệt, không phải của server —
curlvẫn gọi được. - Thứ tự middleware là nguyên nhân số một của lỗi CORS khó hiểu.
- CORS không thay thế xác thực. Nó bảo vệ người dùng, không bảo vệ API.
Tham khảo
Điều hướng
- Bài trước: 2.11 — Mini case study
- Bài tiếp theo: 2.13 — Review and Assessment
- Về module: Trang mục lục