CORS Error khi lên production | Localhost OK, Access-Control-Allow-Origin và preflight
CORS sửa ở server (hoặc edge), không phải ở axios. Localhost hay “ổn” vì Vite proxy khiến API thành same-origin — production gọi https://api… từ https://app… mới kích hoạt CORS. Cần Access-Control-Allow-Origin khớp đúng origin app, xử lý OPTIONS preflight, và gắn header cả trên lỗi 4xx/5xx. Freelance ship SPA+API: đưa origin production vào allowlist trước ngày bàn giao.
Câu chuyện lặp lại trên mọi team Việt Nam làm outsourcing: demo trên máy dev mượt, khách mở https://app.….com — console đỏ blocked by CORS policy, Network đỏ, deadline cận. Postman vẫn 200 nên ai đó kết luận “API ổn, chắc frontend sai”. Thực ra Postman không enforce CORS; trình duyệt mới kiểm Origin.
Bài này tập trung kịch bản local → production, ý nghĩa Access-Control-Allow-Origin, preflight, và checklist giao hàng SPA+API — không phải định nghĩa CORS chung chung.
Bài viết này giúp bạn
- Hiểu vì sao localhost che CORS (dev proxy)
- Đọc đúng lỗi console / Network (simple vs preflight)
- Cấu hình allowlist origin + credentials an toàn
- Ví dụ Express / nginx đủ để ship
- Phân biệt CORS thật với 401/SSL/DNS giả dạng CORS
- Checklist bàn giao cho khách / team VN
Trình duyệt đang hỏi gì?
Trang https://app.example.com gọi https://api.example.com → browser gửi header Origin. Để JavaScript đọc được response, API phải trả tương tự:
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Nếu dùng cookie hoặc credentials: 'include':
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
* cộng credentials = không hợp lệ — phải reflect origin nằm trong allowlist.
Gắn Access-Control-Allow-Origin từ JS phía client vô nghĩa — đó là response header. Extension Chrome “Allow CORS” chỉ tắt kiểm trên máy bạn; user khách hàng không làm vậy.
Localhost vs production — bảng so sánh
| Local dev | Production | |
|---|---|---|
| Origin frontend | http://localhost:5173 | https://app.khachhang.com |
| API trong browser | Thường proxy (same-origin) | https://api.khachhang.com (cross-origin) |
| CORS có chạy? | Thường không (proxy) | Có — bắt buộc ACAO |
| Cookie | SameSite dễ “may” | Secure, domain, SameSite chặt hơn |
| Allowlist hay thiếu | Chỉ có localhost | Quên thêm origin prod / staging |
Dev proxy (Vite server.proxy, Next rewrites) khiến trình duyệt tưởng API cùng origin → CORS không bao giờ chạy. Ship mà không cấu hình origin thật = nổ ngay request cross-origin đầu tiên.
Đây là lý do freelance VN hay gặp: “Máy em chạy được anh ơi” — đúng, vì máy em đang proxy.
Simple request vs preflight
Thường không preflight (simple):
- Method: GET, HEAD, POST
- Content-Type giới hạn:
application/x-www-form-urlencoded,multipart/form-data,text/plain - Ít header tùy chỉnh
Gây preflight (OPTIONS trước):
Content-Type: application/json(SPA hiện đại gần như luôn vậy)Authorization: Bearer …- Method: PUT, PATCH, DELETE
- Header custom (
X-Request-Id, …)
Cả OPTIONS và request thật đều cần CORS header đúng. Chỉ sửa POST mà OPTIONS bị WAF chặn = vẫn fail.
OPTIONS /v1/orders HTTP/1.1
Origin: https://app.khachhang.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,authorization
Server phải trả Allow-Methods / Allow-Headers phủ đúng những gì client xin, rồi POST thật vẫn phải có ACAO.
Sửa phía server (ví dụ thực tế)
Express allowlist
import cors from "cors";
const allowlist = new Set([
"https://app.khachhang.com",
"https://staging.khachhang.com",
"http://localhost:5173",
]);
app.use(
cors({
origin(origin, cb) {
// tool không gửi Origin (curl/server-to-server)
if (!origin || allowlist.has(origin)) return cb(null, true);
return cb(new Error("Not allowed by CORS"));
},
credentials: true,
}),
);
Đảm bảo error middleware vẫn gắn CORS — nếu không, 500 hiện thành “CORS error” mơ hồ trong DevTools.
nginx (edge)
set $cors_origin "";
if ($http_origin ~* ^https://(app\.khachhang\.com|staging\.khachhang\.com)$) {
set $cors_origin $http_origin;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization,Content-Type" always;
add_header Access-Control-Max-Age 86400 always;
return 204;
}
Cờ always quan trọng để response lỗi vẫn giữ header. Nhiều team để app Node/Laravel sở hữu CORS thay vì if nginx (dễ footgun) — chọn một nơi làm nguồn sự thật.
Frontend: việc được làm / không được làm
| Hành động | Có ích? |
|---|---|
mode: 'cors' trên fetch | Mặc định khi đọc cross-origin — ổn |
credentials: 'include' khi cần cookie | Có, kèm ACAO cụ thể phía server |
| Tự set Access-Control-Allow-Origin trong JS | Không — chỉ response mới có |
| Extension Allow CORS | Chỉ máy dev; không phải fix production |
| BFF / proxy cùng domain app | Có — khi không đổi được upstream API |
Khi API bên thứ ba không cho CORS: đặt proxy trên backend của bạn (app → server bạn → API ngoài) để browser thấy same-origin.
Trình tự debug (in và dán vào PR)
- Network → request fail → Response headers.
access-control-allow-origincó không? Khớp từng ký tự với Origin trang (scheme + host + port)? - Preflight — Có OPTIONS không? OPTIONS trả 204/200 + cùng ACAO không?
- Credentials — Dùng cookie thì không được
*; cầnAllow-Credentials: true. - Redirect — 301 trong chuỗi preflight đôi khi làm mất header CORS.
- CDN / Cloudflare — Edge có strip ACAO hoặc cache nhầm origin không? Cần
Vary: Origin. - Env — Staging API thiếu URL staging frontend trong allowlist?
Tái hiện bằng curl (giống browser)
curl -i -X OPTIONS "https://api.khachhang.com/v1/orders" \
-H "Origin: https://app.khachhang.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,authorization"
Không thấy Access-Control-Allow-Origin trong output → browser sẽ chặn dù Postman POST thành công.
Cấu hình sai thường gặp
| Sai | Kết quả |
|---|---|
Allowlist chỉ localhost | Prod app bị chặn |
| ACAO chỉ trên 200, thiếu trên 401/500 | Console báo CORS thay vì auth/error thật |
| WAF chặn OPTIONS | Preflight chết; POST không bao giờ tới |
www vs non-www, trailing slash origin | Fail “lung tung” khó đoán |
* + credentials | Browser reject |
| Dev proxy quên document cho khách | ”Local OK” thành incident bàn giao |
Case study: freelance VN ship SPA + API
Bối cảnh: Frontend Vite trên Vercel, API Laravel trên VPS Hetzner/VNG. Local: proxy: { '/api': 'http://127.0.0.1:8000' }. Production: VITE_API_URL=https://api.client.vn.
Triệu chứng: Form login local OK; trên domain khách — CORS đỏ, OPTIONS 403 từ Cloudflare WAF (rule chặn method lạ).
Cách xử lý:
- curl OPTIONS với Origin production → thiếu ACAO + WAF 403.
- Allow OPTIONS trên WAF; thêm
https://app.client.vnvào CORS Laravel (allowed_origins). - Bật
supports_credentialschỉ khi thật sự dùng cookie session; với Bearer JWT thường không cần credentials cookie — đừng bật “cho chắc”. - Gắn CORS trên exception handler để 422 validation không hóa CORS.
- Sau khi Network xanh: đọc JSON lỗi bằng mắt thường hoặc JSON Formatter — tách transport (CORS) khỏi payload (business error).
Bài học bàn giao: Checklist origin (prod + staging + preview Vercel nếu có) nằm trong Definition of Done, không phải “sửa khi khách báo”.
Khi không phải CORS
- 401/403 thiếu ACAO trên lỗi → nhìn như CORS; sửa error headers.
- Mixed content — HTTPS page gọi HTTP API (lỗi khác, dễ nhầm).
- DNS / SSL — request không tới app; vẫn hiện network error.
- JWT hết hạn — sau khi CORS OK mới thấy 401 thật; đừng debug auth khi preflight còn đỏ.
CORS không có “tool decode” riêng — bản chất là header server. Sau khi thông, dùng JSON Formatter để đọc body; nếu nghi DNS/CNAME lệch môi trường, DNS Check giúp xác nhận trỏ domain — không thay cấu hình Access-Control-*.
Checklist trước ngày go-live
- Allowlist có đúng origin production (và staging)
- OPTIONS preflight 204/200 + Allow-Methods/Headers khớp app
- ACAO trên cả success và error
- Credentials: chỉ bật khi cần; không dùng
*kèm credentials - CDN
Vary: Origin; WAF không chặn OPTIONS - Document cho khách: URL frontend chính thức đã nằm trong allowlist
- curl OPTIONS từ CI hoặc laptop trước khi báo “done”
Tóm tắt
CORS là trình duyệt enforce cross-origin. Localhost thường giấu lỗi sau proxy. Production cần ACAO chính xác, preflight đúng, credentials đúng luật. Sửa server/edge, không sửa bằng folklore phía client. Team outsourcing: coi allowlist origin là hạng mục bàn giao, ngang SSL và DNS.
Liên kết liên quan
- JSON Formatter — đọc body API sau khi CORS đã thông
- DNS Check — khi nghi domain/CNAME trước khi đổ lỗi CORS
- Danh sách công cụ