Back to blog

CORS Giải Thích: Hướng Dẫn Toàn Diện Cho Web Developer

corsweb-securityhttpbackendapi
CORS Giải Thích: Hướng Dẫn Toàn Diện Cho Web Developer

Bạn đang build một frontend app trên localhost:3000. Gọi API trên localhost:8080. Browser quăng ra lỗi đỏ chói:

Access to fetch at 'http://localhost:8080/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy.

Bạn vừa dính cái cơ chế security phổ biến nhất — và cũng bị hiểu lầm nhiều nhất — trên web. Mọi web developer đều gặp CORS, và đa số fix bằng cách bịt mắt thêm Access-Control-Allow-Origin: * mà chẳng hiểu mình đang làm gì.

Bài này giải thích CORS từ đầu: tại sao nó tồn tại, nó hoạt động thế nào bên dưới, và cách configure đúng trong ứng dụng thực tế.

Bạn Sẽ Học Được Gì

✅ Tại sao browser block cross-origin request (same-origin policy)
✅ CORS là gì và nó giải quyết problem gì
✅ Simple request vs. preflight request hoạt động ra sao
✅ Mọi CORS header và chúng control cái gì
✅ Cách configure CORS trong Express, FastAPI, Spring Boot, và Go
✅ Các lỗi CORS phổ biến và cách debug
✅ Security best practice (tại sao * nguy hiểm)

Yêu Cầu Trước Khi Bắt Đầu

Trước khi đi sâu, bạn nên có:


Phần 1: Same-Origin Policy

"Origin" Là Gì?

Một origin được xác định bởi ba phần của URL:

PhầnVí dụ
Scheme (protocol)https
Host (domain)api.example.com
Port443

Hai URL có cùng origin chỉ khi cả ba phần khớp chính xác.

Cùng origin:

  • https://example.com/page1 và https://example.com/page2 — cùng scheme, host, port

Khác origin (cross-origin):

  • http://example.com vs https://example.com — khác scheme
  • https://example.com vs https://api.example.com — khác host (subdomain cũng tính)
  • https://example.com vs https://example.com:8080 — khác port

Tại Sao Same-Origin Policy Tồn Tại?

Same-origin policy (SOP) là một cơ chế security của browser, hạn chế cách script từ origin này tương tác với resource từ origin khác.

Nếu không có nó, bất kỳ website nào cũng có thể:

  1. Đánh cắp data của bạn: Một trang web độc hại có thể fetch inbox Gmail của bạn qua fetch('https://mail.google.com/api/messages') — browser sẽ tự động gắn cookie Gmail của bạn
  2. Thực hiện action thay bạn: Chuyển tiền từ ngân hàng, đăng bài trên social media, đổi password
  3. Đọc response nhạy cảm: Access token, data cá nhân, private API response

Same-origin policy đảm bảo rằng browser sẽ không cho phép website này đọc response từ website khác trừ khi được cho phép rõ ràng.

SOP Block Và Không Block Cái Gì

Bị block bởi SOP (đọc cross-origin response):

  • fetch() và XMLHttpRequest đến origin khác
  • Đọc content từ cross-origin <iframe>
  • Truy cập cross-origin <canvas> data

KHÔNG bị block bởi SOP (embed thì OK):

  • <img src="https://other-site.com/image.png"> — hình ảnh load cross-origin
  • <script src="https://cdn.example.com/lib.js"> — script chạy cross-origin
  • <link href="https://fonts.googleapis.com/..."> — stylesheet load cross-origin
  • <form action="https://other-site.com/submit"> — form submit cross-origin

Điểm mấu chốt: Embed resource cross-origin thì được phép, nhưng đọc cross-origin response bằng code thì bị block.


Phần 2: CORS Là Gì?

Vấn Đề

Same-origin policy tốt cho security, nhưng quá chặt cho web development hiện đại. Những use case hợp lệ bao gồm:

  • Frontend trên app.example.com gọi API trên api.example.com
  • React app trên localhost:3000 gọi backend trên localhost:8080
  • Web view của mobile app gọi API của bạn
  • Third-party integration sử dụng public API của bạn

Giải Pháp: CORS

CORS (Cross-Origin Resource Sharing) là cơ chế cho phép server nói với browser: "Ổn rồi — cho phép cross-origin request này đi."

Nó hoạt động thông qua HTTP header. Server đính kèm các header cụ thể trong response để nói cho browser biết origin, method, và header nào được phép.

CORS không phải security feature bảo vệ server — server vẫn xử lý request bình thường. CORS nói cho browser biết liệu có cho frontend JavaScript đọc response hay không.

Quan trọng: CORS được enforce bởi browser, không phải server. Các tool như curl, Postman, và server-to-server request hoàn toàn bypass CORS vì chúng không implement same-origin policy.


Phần 3: CORS Hoạt Động Thế Nào — Simple vs. Preflight Request

Simple Request

Simple request không trigger preflight check. Browser gửi request trực tiếp và kiểm tra response header.

Một request là "simple" nếu thỏa mãn tất cả điều kiện:

Điều kiệnGiá trị được phép
MethodGET, HEAD, POST
Content-Typetext/plain, multipart/form-data, application/x-www-form-urlencoded
HeadersChỉ CORS-safelisted header (Accept, Accept-Language, Content-Language, Content-Type)

Ví dụ — simple GET request:

// Frontend: https://app.example.com
fetch('https://api.example.com/posts')
  .then(response => response.json())
  .then(data => console.log(data));

Chuyện gì xảy ra bên dưới:

GET /posts HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
 
[{"id": 1, "title": "Hello World"}]

Browser thấy Access-Control-Allow-Origin khớp với origin đang request → JavaScript đọc được response.

Preflight Request

Bất kỳ request nào không qualify là "simple" đều trigger preflight request — một OPTIONS request phụ hỏi server xin phép trước khi gửi request thật.

Những thứ trigger preflight:

  • Dùng method như PUT, DELETE, PATCH
  • Gửi Content-Type: application/json
  • Đính kèm custom header như Authorization, X-Request-ID

Ví dụ — POST với JSON (trigger preflight):

// Frontend: https://app.example.com
fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...'
  },
  body: JSON.stringify({ name: 'Alice' })
});

Bước 1: Browser gửi OPTIONS preflight:

OPTIONS /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

Bước 2: Server phản hồi với permission:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600

Bước 3: Browser gửi request thật (nếu preflight pass):

POST /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
 
{"name": "Alice"}

Toàn bộ flow minh họa:

Preflight Caching

Preflight request thêm latency vì mỗi cross-origin API call cần hai round trip. Header Access-Control-Max-Age nói browser cache kết quả preflight:

Access-Control-Max-Age: 3600  // Cache 1 giờ

Sau khi cache, các request tiếp theo đến cùng endpoint sẽ bỏ qua preflight trong suốt thời gian cache.

BrowserAccess-Control-Max-Age tối đa
Chrome7200 (2 giờ)
Firefox86400 (24 giờ)
Safari604800 (7 ngày)

Phần 4: Các CORS Header Giải Thích

Request Header (Browser → Server)

Các header này browser tự động set — bạn không bao giờ set thủ công:

HeaderMục đíchVí dụ
OriginOrigin đang tạo requesthttps://app.example.com
Access-Control-Request-MethodMethod mà request thật sẽ dùng (chỉ preflight)PUT
Access-Control-Request-HeadersCustom header mà request thật sẽ gửi (chỉ preflight)Content-Type, Authorization

Response Header (Server → Browser)

Đây là các header server của bạn phải set:

HeaderMục đíchVí dụ
Access-Control-Allow-OriginOrigin nào được phéphttps://app.example.com hoặc *
Access-Control-Allow-MethodsHTTP method nào được phépGET, POST, PUT, DELETE
Access-Control-Allow-HeadersRequest header nào được phépContent-Type, Authorization
Access-Control-Expose-HeadersResponse header nào JavaScript đọc đượcX-Total-Count, X-Request-ID
Access-Control-Allow-CredentialsCho phép cookie/auth headertrue
Access-Control-Max-AgeThời gian cache preflight (giây)3600

Vấn Đề Credentials

Mặc định, cross-origin request không gửi kèm cookie hay Authorization header. Để gửi credential:

Frontend phải opt in:

fetch('https://api.example.com/profile', {
  credentials: 'include'  // Gửi cookie cross-origin
});

Server phải opt in:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://app.example.com  // PHẢI cụ thể, KHÔNG được dùng *

Quy tắc quan trọng: Khi set Access-Control-Allow-Credentials: true, Access-Control-Allow-Origin không được là *. Bạn phải chỉ định origin cụ thể. Đây là security feature — wildcard + credential sẽ cho phép bất kỳ website nào tạo authenticated request đến API của bạn.


Phần 5: Configure CORS Trong Ứng Dụng Thực Tế

Node.js / Express

const express = require('express');
const cors = require('cors');
const app = express();
 
// Option 1: Cho phép mọi origin (chỉ dùng khi dev!)
app.use(cors());
 
// Option 2: Origin cụ thể
app.use(cors({
  origin: 'https://app.example.com'
}));
 
// Option 3: Nhiều origin với credential
app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 3600
}));
 
// Option 4: Validate origin động
app.use(cors({
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://app.example.com',
      'https://admin.example.com'
    ];
 
    // Cho phép request không có origin (server-to-server, curl, v.v.)
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true
}));
 
app.listen(8080);

Python / FastAPI

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
 
app = FastAPI()
 
# Thêm CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com", "https://admin.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
    max_age=3600,
)
 
@app.get("/api/users")
async def get_users():
    return [{"id": 1, "name": "Alice"}]

Java / Spring Boot

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
 
@Configuration
public class CorsConfig {
 
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("https://app.example.com");
        config.addAllowedOrigin("https://admin.example.com");
        config.addAllowedMethod("*");
        config.addAllowedHeader("*");
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return new CorsFilter(source);
    }
}

Hoặc dùng annotation cho từng controller:

@RestController
@CrossOrigin(origins = "https://app.example.com", maxAge = 3600)
@RequestMapping("/api/users")
public class UserController {
 
    @GetMapping
    public List<User> getUsers() {
        return userService.findAll();
    }
}

Go (net/http)

package main
 
import (
	"encoding/json"
	"net/http"
	"slices"
)
 
func corsMiddleware(next http.Handler) http.Handler {
	allowedOrigins := []string{
		"https://app.example.com",
		"https://admin.example.com",
	}
 
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")
 
		if slices.Contains(allowedOrigins, origin) {
			w.Header().Set("Access-Control-Allow-Origin", origin)
			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
			w.Header().Set("Access-Control-Allow-Credentials", "true")
			w.Header().Set("Access-Control-Max-Age", "3600")
		}
 
		// Xử lý preflight
		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
 
		next.ServeHTTP(w, r)
	})
}
 
func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /api/users", func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode(map[string]string{"name": "Alice"})
	})
 
	http.ListenAndServe(":8080", corsMiddleware(mux))
}

Nginx (Reverse Proxy)

Nếu backend không xử lý CORS, configure ở tầng Nginx:

server {
    listen 80;
    server_name api.example.com;
 
    location /api/ {
        # CORS headers
        add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Max-Age' '3600' always;
 
        # Xử lý preflight
        if ($request_method = 'OPTIONS') {
            return 204;
        }
 
        proxy_pass http://localhost:8080;
    }
}

Cảnh báo: Đừng set CORS header ở cả Nginx lẫn application — bạn sẽ có duplicate header, và browser sẽ reject.


Phần 6: Các Lỗi CORS Phổ Biến Và Cách Debug

Lỗi 1: "No 'Access-Control-Allow-Origin' header is present"

Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

Nguyên nhân: Server không trả về header Access-Control-Allow-Origin.

Fix: Thêm CORS header vào server response. Đảm bảo chúng được trả về cho mọi response, kể cả error response (4xx, 5xx).

Lỗi 2: "The 'Access-Control-Allow-Origin' header has a value that is not equal to the supplied origin"

Nguyên nhân: Server trả về Access-Control-Allow-Origin: https://other-site.com nhưng app của bạn ở https://app.example.com.

Fix: Cập nhật danh sách allowed origin trên server để bao gồm origin đang request.

Lỗi 3: "Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'"

Nguyên nhân: Bạn gửi credentials: 'include' nhưng server phản hồi Access-Control-Allow-Origin: *.

Fix: Dùng origin cụ thể thay vì * khi cần credential.

Lỗi 4: "Method PUT is not allowed by Access-Control-Allow-Methods"

Nguyên nhân: Preflight check fail vì server không liệt kê PUT trong Access-Control-Allow-Methods.

Fix: Thêm method thiếu vào CORS configuration.

Lỗi 5: "Request header field Authorization is not allowed"

Nguyên nhân: Header Authorization không được liệt kê trong Access-Control-Allow-Headers.

Fix: Thêm Authorization vào allowed header configuration.

Checklist Debug

Khi gặp lỗi CORS, làm theo quy trình có hệ thống:

  1. Mở DevTools → Network tab: Tìm request bị fail. Tìm preflight (OPTIONS) request màu đỏ.

  2. Kiểm tra preflight response: Xem server có trả về đúng Access-Control-* header không.

  3. Dùng curl để verify: Test mà không có browser CORS enforcement:

    # Kiểm tra server có trả CORS header không
    curl -i -X OPTIONS https://api.example.com/data \
      -H "Origin: https://app.example.com" \
      -H "Access-Control-Request-Method: POST" \
      -H "Access-Control-Request-Headers: Content-Type, Authorization"
  4. Kiểm tra error response: CORS header phải có mặt trên mọi response, kể cả 401, 403, 404, và 500. Sai lầm phổ biến là chỉ thêm CORS header cho response thành công.

  5. Kiểm tra duplicate header: Nếu bạn configure CORS ở cả reverse proxy (Nginx) lẫn application, browser có thể reject duplicate header.


Phần 7: Security Best Practice

1. Không Bao Giờ Dùng * Trong Production Với Credential

// ❌ NGUY HIỂM: cho phép bất kỳ website nào gọi API
app.use(cors({ origin: '*' }));
 
// ✅ AN TOÀN: chỉ frontend của bạn gọi được API
app.use(cors({ origin: 'https://app.example.com' }));

Dùng Access-Control-Allow-Origin: * nghĩa là bất kỳ website nào cũng đọc được response từ API. Điều này ổn cho API thực sự public (ví dụ API thời tiết), nhưng nguy hiểm cho API phục vụ data theo user.

2. Validate Origin Động

Với ứng dụng multi-tenant hoặc nhiều frontend, validate dựa trên whitelist:

// ❌ TỆ: regex quá rộng
origin: /example\.com$/  // Match cả evil-example.com!
 
// ✅ TỐT: whitelist rõ ràng
const allowedOrigins = new Set([
  'https://app.example.com',
  'https://admin.example.com',
  'https://staging.example.com'
]);
 
origin: (origin, callback) => {
  if (!origin || allowedOrigins.has(origin)) {
    callback(null, true);
  } else {
    callback(new Error('Not allowed by CORS'));
  }
}

3. Đừng Reflect Origin Header Một Cách Mù Quáng

// ❌ CỰC KỲ NGUY HIỂM: reflect bất kỳ origin nào
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  next();
});

Cách này tương đương * với credential — bất kỳ site nào cũng tạo được authenticated request đến API của bạn.

4. Giới Hạn Method Và Header Được Expose

Chỉ expose những gì API thực sự cần:

// ❌ Quá rộng
methods: '*',
allowedHeaders: '*',
 
// ✅ Chỉ những gì cần
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization'],

5. Cân Nhắc Alternative Thay Vì CORS

Đôi khi bạn không cần CORS:

ScenarioGiải pháp
Frontend và API cùng domainKhông cần CORS — dùng path-based routing (/api/*)
Next.js app với API routeAPI route chạy trên cùng origin
Reverse proxy (Nginx)Proxy /api request đến backend — browser thấy cùng origin
BFF (Backend for Frontend)Server-side API call không có CORS restriction

Phần 8: CORS Trong Các Scenario Khác Nhau

Single-Page Application (SPA)

Hầu hết SPA có frontend và backend tách biệt. CORS thường cần trong cả development lẫn production:

// Development: React trên :3000, API trên :8080
app.use(cors({
  origin: process.env.NODE_ENV === 'production'
    ? 'https://app.example.com'
    : 'http://localhost:3000',
  credentials: true
}));

Microservices

Trong microservices architecture, CORS nên được configure ở API gateway level, không phải trong từng service riêng lẻ:

Public API

Với API thực sự public không có authentication:

app.use(cors({
  origin: '*',           // Ai cũng dùng được API
  methods: ['GET'],      // Chỉ đọc
  // Không hỗ trợ credential
}));

WebSocket Và Server-Sent Events

CORS không áp dụng cho WebSocket connection (ws:// hoặc wss://). WebSocket handshake dùng Origin header, nhưng server phải validate thủ công — browser không enforce CORS cho WebSocket.

Server-Sent Events (SSE) dùng HTTP thông thường, nên CORS áp dụng bình thường.


Tổng Kết

Same-origin policy block cross-origin read để ngăn đánh cắp data. CORS nới lỏng restriction này một cách có chọn lọc thông qua HTTP header.

Những điều cốt lõi:

  • CORS là cơ chế của browser — curl và server-to-server call bỏ qua nó
  • Simple request (GET/POST với basic header) gửi trực tiếp; preflight request (OPTIONS) kiểm tra permission trước
  • Access-Control-Allow-Origin là header quan trọng nhất — set origin cụ thể, đừng dùng *
  • Khi gửi cookie hay auth header, cần credentials: 'include' ở client và Access-Control-Allow-Credentials: true ở server (với origin cụ thể)
  • Cache preflight response bằng Access-Control-Max-Age để giảm latency
  • Cân nhắc same-origin architecture (reverse proxy, BFF pattern) để tránh CORS hoàn toàn

Sai lầm phổ biến cần tránh:

  • Dùng * với credential
  • Reflect Origin header mà không validate
  • Quên CORS header trên error response
  • Configure CORS ở cả Nginx lẫn application (duplicate header)
  • Dùng regex quá rộng để validate origin

Bài Viết Liên Quan

Nếu thấy bài này hữu ích, xem thêm các bài liên quan:

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.