CORS Error khi lên production | Localhost OK, Access-Control-Allow-Origin và preflight

(Cập nhật: 19 tháng 7, 2026 ) CORS Access-Control-Allow-Origin preflight API SPA production
Kết luận

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.

Không sửa CORS bằng React

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 devProduction
Origin frontendhttp://localhost:5173https://app.khachhang.com
API trong browserThường proxy (same-origin)https://api.khachhang.com (cross-origin)
CORS có chạy?Thường không (proxy) — bắt buộc ACAO
CookieSameSite dễ “may”Secure, domain, SameSite chặt hơn
Allowlist hay thiếuChỉ có localhostQuê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

Checklist phía client
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)

  1. Network → request fail → Response headers. access-control-allow-origin có không? Khớp từng ký tự với Origin trang (scheme + host + port)?
  2. Preflight — Có OPTIONS không? OPTIONS trả 204/200 + cùng ACAO không?
  3. Credentials — Dùng cookie thì không được *; cần Allow-Credentials: true.
  4. Redirect — 301 trong chuỗi preflight đôi khi làm mất header CORS.
  5. CDN / Cloudflare — Edge có strip ACAO hoặc cache nhầm origin không? Cần Vary: Origin.
  6. 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

SaiKết quả
Allowlist chỉ localhostProd app bị chặn
ACAO chỉ trên 200, thiếu trên 401/500Console báo CORS thay vì auth/error thật
WAF chặn OPTIONSPreflight chết; POST không bao giờ tới
www vs non-www, trailing slash originFail “lung tung” khó đoán
* + credentialsBrowser 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ý:

  1. curl OPTIONS với Origin production → thiếu ACAO + WAF 403.
  2. Allow OPTIONS trên WAF; thêm https://app.client.vn vào CORS Laravel (allowed_origins).
  3. Bật supports_credentials chỉ 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”.
  4. Gắn CORS trên exception handler để 422 validation không hóa CORS.
  5. 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