Làm backend thì ngày nào cũng làm việc với HTTP, nhưng ít ai trả lời được câu hỏi “vì sao HTTP lại thiết kế như vậy?”. Bài này đi từ vài nguyên lý cốt lõi, rồi dùng chúng để giải thích message, method, status code và cache — vì phần lớn những thứ này là hệ quả trực tiếp của các nguyên lý đó. Phần cuối nói về CORS. CORS không nằm trong chuẩn HTTP, mà là cơ chế riêng của browser, hoạt động bằng các HTTP header.
Các nguyên lý cốt lõi của HTTP
HTTP (Hypertext Transfer Protocol) là giao thức để client và server trao đổi dữ liệu trên web. Toàn bộ HTTP xoay quanh sáu nguyên lý dưới đây.
Client và server
HTTP chỉ có hai vai: client là bên gửi yêu cầu, server là bên xử lý và trả kết quả.
Client ở đây không chỉ là browser. App mobile, curl, Postman, hay một service backend gọi sang service khác — tất cả đều là client. Điểm cần nhớ: server không biết và cũng không cần biết bên gọi là ai. Nó chỉ dựa vào những gì có trong request để xử lý.
Request và response
Mọi cuộc trao đổi trong HTTP đều theo một khuôn mẫu duy nhất: client gửi một request, server trả về đúng một response.
Từ khuôn mẫu này có hai hệ quả bạn nên nắm:
- Client luôn là bên gửi request. Server chỉ gửi dữ liệu khi đang trả lời một request.
- Request nào cũng có response. Kể cả khi xử lý thất bại, server vẫn trả về một response để báo lỗi.
Vậy nếu server muốn chủ động đẩy dữ liệu xuống client — ví dụ thông báo realtime — thì sao? HTTP thông thường không làm được, và đó là lúc chúng ta cần tới WebSocket hoặc các kỹ thuật khác, được nói trong ghi chép về WebSocket.
Stateless
HTTP là giao thức stateless: server không nhớ gì về các request trước đó. Mỗi request phải tự mang theo đủ thông tin để server xử lý.
Nghe có vẻ là hạn chế, nhưng đây lại là một trong những quyết định thiết kế quan trọng nhất của HTTP. Hãy xem hai hệ quả của nó:
- Instance nào cũng xử lý được request nào. Vì server không giữ trạng thái giữa các request, bạn có thể chạy mười instance giống hệt nhau phía sau load balancer. Request rơi vào instance nào cũng xử lý được. Đây chính là nền tảng để scale ngang.
- Client phải tự gửi lại ngữ cảnh ở mỗi request. Ví dụ, server không “nhớ” bạn đã đăng nhập. Mỗi request, client phải gửi kèm cookie hoặc token để chứng minh mình là ai. Ghi chép về authentication sẽ đi sâu vào phần này.
Lưu ý: stateless không có nghĩa là hệ thống không lưu gì. Database vẫn lưu user, đơn hàng như bình thường. Stateless chỉ nói rằng bản thân giao thức không giữ trạng thái giữa các request.
Self-descriptive message
Nguyên lý này là hệ quả trực tiếp của stateless. Server không nhớ gì, nên mỗi message phải tự nói lên tất cả về chính nó. Ngoài dữ liệu, message còn kèm thông tin mô tả dữ liệu đó: là JSON hay HTML, dài bao nhiêu byte, ngôn ngữ gì, có được cache không. Message như vậy được gọi là self-descriptive.
Toàn bộ phần thông tin mô tả này nằm trong header — chúng ta sẽ quay lại ở phần cấu trúc message.
Intermediary
Trong thực tế, request hiếm khi đi thẳng từ client tới server. Trên đường đi, nó thường qua một vài intermediary như proxy, load balancer, API gateway hay CDN.
Sơ đồ dưới đây là đường đi của một lần gọi: request đi từ client, qua intermediary, tới server; response đi ngược lại theo cùng con đường.
flowchart LR
C["Client<br/>browser, app, service"] -->|request| I["Intermediary<br/>proxy, load balancer, CDN"]
I -->|request| S["Server"]
S -.->|response| I
I -.->|response| C
classDef main fill:#dbeafe,stroke:#1e40af,color:#1e3a8a
classDef infra fill:#f4f4f5,stroke:#52525b,color:#27272a
class C,S main
class I infra
Câu hỏi thú vị là: các intermediary này không biết gì về logic ứng dụng của bạn, vậy làm sao chúng làm đúng việc? Câu trả lời nằm ở nguyên lý self-descriptive. Mọi thông tin chúng cần đều nằm sẵn trong message. CDN thấy response có header Cache-Control: max-age=600 là biết được phép cache trong 10 phút, không cần hỏi lại server. Load balancer nhìn header Host là biết chuyển request tới service nào.
Muốn vậy thì mọi bên trên đường đi phải hiểu method, status code và header theo cùng một nghĩa. Đó là lý do HTTP chuẩn hoá ý nghĩa của chúng trong các RFC.
Extensibility
HTTP ra đời từ đầu những năm 90, nhưng tới giờ vẫn liên tục có thêm header mới, status code mới. Điều đáng chú ý là việc thêm này không làm hỏng các phần mềm đã chạy từ trước. Nói cách khác, HTTP được thiết kế để extensible và backward compatible ngay từ đầu.
Bí quyết rất đơn giản: phần mềm gặp header mà nó không hiểu thì cứ bỏ qua, không báo lỗi. Nhờ quy ước đó, CORS — một cơ chế ra đời rất lâu sau HTTP — có thể được thêm vào chỉ bằng vài header mới, mà không cần sửa lại giao thức.
Các nguyên lý liên quan với nhau thế nào
Nhìn lại cả sáu nguyên lý, bạn sẽ thấy chúng không đứng riêng lẻ mà dựa vào nhau:
| Nguyên lý | Hiểu đơn giản | Nhờ đó mà |
|---|---|---|
| Client–server | Hai vai rõ ràng | Một server phục vụ được nhiều loại client |
| Request–response | Mỗi request có đúng một response | Trao đổi đơn giản, dễ đoán |
| Stateless | Request tự mang đủ thông tin | Scale bằng cách chạy thêm instance |
| Self-descriptive | Message tự mô tả nội dung của nó | Cache, proxy làm việc mà không cần hỏi app |
| Intermediary | Mọi bên hiểu method, code, header như nhau | Có load balancer, CDN, gateway ở giữa |
| Extensibility | Gặp phần không hiểu thì bỏ qua | Thêm tính năng mới mà không phá cái cũ |
HTTP nằm ở đâu trong mạng
Một điểm hay bị nhầm: HTTP không tự truyền dữ liệu qua mạng. Nó chỉ quy định message có nghĩa là gì. Việc đưa từng byte từ máy này sang máy kia là việc của các layer bên dưới.
| Layer | Phụ trách | Ví dụ |
|---|---|---|
| Application | Ý nghĩa của message | HTTP |
| Security | Mã hoá dữ liệu, xác minh server | TLS |
| Transport | Truyền dữ liệu đầy đủ, đúng thứ tự giữa hai máy | TCP, QUIC |
| Network | Định tuyến packet giữa các máy | IP |
Chính sự tách biệt này giúp ý nghĩa của HTTP chỉ cần định nghĩa một lần, trong RFC 9110. HTTP/1.1, HTTP/2 và HTTP/3 dùng chung method, status code và header; chúng chỉ khác nhau ở cách truyền message qua mạng. Phần cuối bài sẽ so sánh ba phiên bản này.
Cấu trúc của một HTTP message
Quay lại nguyên lý self-descriptive: để message tự mô tả được, nó cần một cấu trúc cố định mà bên nào cũng đọc được. Request hay response đều gồm bốn phần:
- Start line: dòng đầu tiên, cho biết đây là request gì hoặc kết quả ra sao.
- Header: các thông tin mô tả message.
- Một dòng trống: báo hiệu phần header đã hết.
- Body: dữ liệu chính, có thể không có.
Request
POST /orders HTTP/1.1
Host: api.shop.test
Content-Type: application/json
Accept: application/json
Content-Length: 25
{"sku": "A-42", "qty": 1}
Start line của request gọi là request line, gồm ba phần:
| Phần | Trong ví dụ | Ý nghĩa |
|---|---|---|
| Method | POST | Client muốn làm gì |
| Target | /orders | Làm trên resource nào |
| Version | HTTP/1.1 | Phần còn lại của message theo định dạng nào |
Bạn để ý header Host: nó cho biết request gửi tới domain nào. HTTP/1.1 bắt buộc phải có header này, vì một server thường chạy nhiều domain trên cùng một địa chỉ IP.
Response
HTTP/1.1 201 Created
Content-Type: application/json
Location: /orders/9001
Content-Length: 33
{"id": 9001, "status": "pending"}
Start line của response gọi là status line, gồm phiên bản, status code ba chữ số và một reason phrase ngắn. Code của bạn nên xử lý dựa vào status code; reason phrase chỉ để con người đọc log cho dễ hiểu.
Header
Header là các cặp tên–giá trị, tên không phân biệt hoa thường. Có rất nhiều header, nhưng chia theo mục đích thì chỉ có vài nhóm:
| Nhóm | Trả lời câu hỏi | Ví dụ |
|---|---|---|
| Thông tin request | Ai gọi, gọi tới đâu? | Host, User-Agent, Authorization |
| Content negotiation | Client nhận được những dạng dữ liệu nào? | Accept, Accept-Language, Accept-Encoding |
| Representation | Body có định dạng gì, dài bao nhiêu? | Content-Type, Content-Length, Content-Encoding |
| Cache | Có được cache không, trong bao lâu? | Cache-Control, ETag, If-None-Match, Vary |
| Thông tin response | Server muốn cho biết thêm điều gì? | Location, Retry-After, Allow |
Body và độ dài của body
Một câu hỏi tưởng đơn giản: bên nhận làm sao biết body kết thúc ở đâu? HTTP/1.1 có hai cách:
Content-Length: ghi rõ body dài bao nhiêu byte. Bên nhận đọc đủ số byte đó là xong.Transfer-Encoding: chunked: gửi body thành từng chunk, mỗi chunk ghi kích thước riêng, kết thúc bằng một chunk rỗng. Cách này dùng khi server bắt đầu gửi mà chưa biết tổng kích thước — ví dụ khi stream một file export lớn.
Với HTTP/2 và HTTP/3, message được chia thành các frame ngay từ tầng giao thức, nên không cần hai header này để xác định điểm kết thúc của body.
Content negotiation
Cùng một resource có thể được trả về ở nhiều dạng: JSON hay XML, tiếng Anh hay tiếng Việt, có nén hay không. Mỗi dạng như vậy gọi là một representation. Còn content negotiation là cách client và server “thương lượng” xem sẽ trả dạng nào.
Cách làm rất trực quan: client nói mình nhận được những dạng nào, server chọn một dạng rồi ghi rõ đã chọn gì trong response.
| Client gửi | Server trả lời kèm |
|---|---|
Accept: application/json | Content-Type: application/json |
Accept-Language: vi, en;q=0.8 | Content-Language: vi |
Accept-Encoding: gzip, br | Content-Encoding: br |
Giá trị q là mức ưu tiên, từ 0 đến 1. Ở ví dụ trên, client muốn tiếng Việt trước, nếu không có thì tiếng Anh cũng được.
Method
Chữ đầu tiên của request line là method. Method cho server biết client muốn làm gì với resource.
Hai tính chất: safe và idempotent
Khi học về method, đừng chỉ nhớ tên. Điều quan trọng hơn là hai tính chất mà chuẩn HTTP định nghĩa cho chúng:
- Safe: method chỉ đọc, không yêu cầu server thay đổi dữ liệu gì.
- Idempotent: gọi một lần hay gọi nhiều lần thì kết quả cuối cùng trên server vẫn như nhau.
Dễ thấy method safe thì chắc chắn idempotent: đã không thay đổi gì thì gọi bao nhiêu lần cũng vậy.
Các method thường gặp
| Method | Dùng để | Safe | Idempotent |
|---|---|---|---|
GET | Đọc một resource | có | có |
HEAD | Như GET nhưng chỉ lấy header | có | có |
OPTIONS | Hỏi server hỗ trợ những gì | có | có |
POST | Tạo resource mới hoặc chạy một tác vụ | không | không |
PUT | Ghi đè toàn bộ resource ở một URL cụ thể | không | có |
PATCH | Sửa một phần resource | không | không đảm bảo |
DELETE | Xoá resource | không | có |
PATCH được định nghĩa trong RFC 5789; các method còn lại nằm trong RFC 9110.
Lưu ý: idempotent nói về kết quả trên server, không nói về response. Gọi
DELETEhai lần, lần đầu bạn nhận204, lần sau nhận404— response khác nhau, nhưng kết quả trên server thì như nhau: resource đã bị xoá.
Vì sao hai tính chất này quan trọng
Hai tính chất này không phải lý thuyết suông. Chúng cho mọi bên trên đường đi biết request nào được phép tự gửi lại.
- Với method safe, browser có thể tải trước trang, cache có thể lưu response, mà không lo làm thay đổi dữ liệu.
- Với method idempotent, client có thể retry thoải mái. Hãy hình dung kết nối bị đứt khi response chưa kịp về: client không biết server đã xử lý hay chưa. Nếu method là idempotent, cứ gửi lại là xong.
POSTthì không có tính chất nào trong hai. Retry mộtPOSTcó thể tạo ra bản ghi thứ hai. Để retry an toàn, chúng ta dùng idempotency key: client gửi kèm một giá trị duy nhất, server dựa vào đó để nhận ra request bị gửi lặp. Ghi chép về thiết kế REST API sẽ nói chi tiết.
Status code
Mỗi request nhận về một response, và status code là cách nhanh nhất để biết kết quả. Đây là số có ba chữ số; chữ số đầu tiên cho biết nó thuộc nhóm nào.
Năm nhóm
| Nhóm | Ý nghĩa | Client nên làm gì |
|---|---|---|
1xx | Thông tin | Tiếp tục chờ |
2xx | Thành công | Không cần làm gì thêm |
3xx | Chuyển hướng | Gọi tới địa chỉ khác |
4xx | Lỗi phía client | Sửa request rồi mới gửi lại |
5xx | Lỗi phía server | Chờ server khắc phục; có thể retry |
Trong năm nhóm, ranh giới giữa 4xx và 5xx là thứ bạn cần hiểu kỹ nhất, vì nó là ranh giới trách nhiệm. 4xx nghĩa là lỗi nằm ở request — gửi lại y nguyên cũng vô ích. 5xx nghĩa là lỗi nằm ở server — lát nữa thử lại có thể thành công. Logic retry và hệ thống monitoring đều dựa vào ranh giới này. Trả nhầm 500 cho một request sai dữ liệu, bạn có thể vô tình kích hoạt cảnh báo cho cả team on-call.
Các status code thường gặp
| Code | Tên | Ý nghĩa |
|---|---|---|
200 | OK | Thành công, có body |
201 | Created | Đã tạo resource mới; URL của nó nằm trong Location |
202 | Accepted | Đã nhận request, sẽ xử lý sau |
204 | No Content | Thành công, không có body |
301 | Moved Permanently | Resource đã chuyển hẳn sang URL mới |
302 | Found | Resource tạm thời ở URL khác |
304 | Not Modified | Bản cache của client vẫn dùng được |
307 | Temporary Redirect | Như 302, nhưng phải giữ nguyên method và body |
308 | Permanent Redirect | Như 301, nhưng phải giữ nguyên method và body |
400 | Bad Request | Request sai định dạng |
401 | Unauthorized | Chưa authenticate |
403 | Forbidden | Đã authenticate nhưng không có quyền |
404 | Not Found | Không tìm thấy resource |
405 | Method Not Allowed | Resource không hỗ trợ method này |
409 | Conflict | Request xung đột với trạng thái hiện tại của resource |
415 | Unsupported Media Type | Server không nhận định dạng body này |
422 | Unprocessable Content | Đúng định dạng nhưng dữ liệu không hợp lệ |
429 | Too Many Requests | Gửi quá nhiều request, bị rate limit |
500 | Internal Server Error | Server gặp lỗi không lường trước |
502 | Bad Gateway | Proxy nhận response không hợp lệ từ server phía sau |
503 | Service Unavailable | Server tạm thời không phục vụ được |
504 | Gateway Timeout | Proxy chờ server phía sau quá lâu |
Lưu ý: đây là chỗ rất nhiều người nhầm. Tên là “Unauthorized” nhưng
401thực ra nghĩa là chưa authenticate — server chưa biết bạn là ai. Còn403nghĩa là server biết bạn là ai, nhưng bạn không có quyền làm việc này.
Cache
Bây giờ bạn sẽ thấy rõ vì sao hai nguyên lý self-descriptive và intermediary quan trọng: nhờ chúng mà HTTP có cơ chế cache sẵn. Cache lưu lại response để dùng cho các request sau, server không phải xử lý lại từ đầu.
Muốn cache đúng, phải trả lời được hai câu hỏi. Thứ nhất, response này dùng lại được trong bao lâu? Thứ hai, khi đã hết hạn, dữ liệu thật sự có thay đổi chưa? HTTP trả lời hai câu hỏi này bằng hai cơ chế tương ứng: freshness và validation.
Freshness
Server cho biết response được dùng lại trong bao lâu qua header Cache-Control.
| Directive | Ý nghĩa |
|---|---|
max-age=600 | Dùng lại được trong 600 giây |
no-cache | Được lưu, nhưng phải hỏi lại server trước mỗi lần dùng |
no-store | Không được lưu |
private | Chỉ browser của user được lưu, CDN thì không |
public | Cache dùng chung như CDN cũng được lưu |
Lưu ý: cái tên
no-cacherất dễ gây hiểu lầm. Nó không có nghĩa là “không cache”, mà là “được lưu, nhưng phải hỏi lại server trước khi dùng”. Muốn cấm lưu hẳn, bạn phải dùngno-store.
Validation
Khi bản cache đã hết hạn (stale), cache không nhất thiết phải tải lại toàn bộ. Nó chỉ cần hỏi server một câu: “dữ liệu có đổi không?”
Để hỏi được, server gắn cho mỗi phiên bản của response một ETag — một mã ngắn đại diện cho đúng nội dung đó. Lần sau, cache gửi lại mã này trong header If-None-Match. Nếu dữ liệu chưa đổi, server chỉ trả 304 Not Modified không kèm body, và cache dùng tiếp bản cũ. Với một response lớn, cách này tiết kiệm băng thông đáng kể.
Response thay đổi theo request
Có một chi tiết dễ bị bỏ sót. Vì có content negotiation, cùng một URL có thể trả về nhiều representation khác nhau. Cache tuyệt đối không được đưa bản tiếng Việt cho người đang hỏi bản tiếng Anh. Header Vary giải quyết chuyện này: nó cho cache biết response phụ thuộc vào request header nào, ví dụ Vary: Accept-Language. Khi đó cache lưu riêng một bản cho mỗi giá trị của header đó.
Cache phía server là một chủ đề lớn, được nói riêng trong ghi chép về caching.
Các phiên bản HTTP
Như đã nói, ý nghĩa của HTTP giống nhau ở mọi phiên bản. Cái thay đổi qua từng phiên bản là cách truyền message qua mạng — cụ thể là cách nhiều request chia sẻ một kết nối.
HTTP/1.1
HTTP/1.1 gửi message dạng text qua TCP. Một kết nối có thể dùng lại cho nhiều request, nhưng mỗi lúc chỉ xử lý một request. Request sau phải đợi response của request trước về xong.
Vấn đề này có tên là head-of-line blocking: chỉ cần một response chậm, mọi request xếp hàng phía sau đều phải chờ theo. Browser khắc phục bằng cách mở song song nhiều kết nối tới cùng một server — một giải pháp chữa cháy hơn là giải quyết tận gốc.
HTTP/2
HTTP/2 giải quyết vấn đề trên bằng cách chia message thành các binary frame. Frame của nhiều request được gửi xen kẽ trên cùng một kết nối, mỗi request thuộc một stream riêng. Nhờ vậy, nhiều request chạy song song trên một kết nối duy nhất. HTTP/2 còn nén header để giảm dung lượng truyền đi.
Tuy nhiên, head-of-line blocking vẫn chưa biến mất hoàn toàn — nó chỉ chuyển xuống tầng dưới. Mọi stream vẫn dùng chung một kết nối TCP, mà TCP bắt buộc dữ liệu tới đúng thứ tự. Chỉ cần mất một packet, tất cả các stream đều phải đợi packet đó được gửi lại.
HTTP/3
HTTP/3 giải quyết nốt vấn đề này bằng cách bỏ TCP, chuyển sang QUIC — một transport protocol xây trên UDP. QUIC xử lý từng stream độc lập, nên mất một packet chỉ làm chậm đúng stream chứa packet đó. Ngoài ra, QUIC gộp bước bắt tay kết nối với bước bắt tay TLS, nên kết nối mới được thiết lập nhanh hơn.
So sánh
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Transport | TCP | TCP | QUIC trên UDP |
| Định dạng | Text | Binary frame | Binary frame |
| Request trên một kết nối | Lần lượt từng cái | Song song | Song song |
| Head-of-line blocking | Ở tầng HTTP | Ở tầng TCP | Chỉ trong một stream |
| Specification | RFC 9112 | RFC 9113 | RFC 9114 |
Origin và same-origin policy
Mọi thứ chúng ta nói từ đầu bài tới giờ đều đúng với bất kỳ HTTP client nào. Bản thân HTTP không có quy tắc nào về việc ai được đọc response. Nhưng browser thì cần một quy tắc như vậy. Lý do là browser chạy code của rất nhiều website trong cùng một nơi, và đang giữ cookie đăng nhập của bạn cho tất cả các website đó. Để hiểu quy tắc này, trước hết cần biết origin là gì.
Origin
Origin của một URL gồm ba phần: scheme, host và port (RFC 6454). Hai URL chỉ cùng origin khi cả ba phần đều giống nhau.
| URL | Cùng origin với https://shop.test? | Lý do |
|---|---|---|
https://shop.test/cart | có | Path không tính vào origin |
http://shop.test | không | Khác scheme |
https://api.shop.test | không | Khác host |
https://shop.test:8443 | không | Khác port |
Một hệ quả mà developer nào cũng từng gặp: frontend chạy ở localhost:5173 và API chạy ở localhost:8080 là hai origin khác nhau, dù cùng một máy.
Same-origin policy
Same-origin policy là quy tắc của browser: JavaScript trên một trang được gửi request sang origin khác, nhưng không được đọc response, trừ khi origin kia cho phép.
Vì sao lại chặn việc đọc? Câu trả lời nằm ở cookie. Khi trang A gửi request sang origin B, browser tự động gắn cookie của bạn ở B vào request. Nếu JavaScript của trang A đọc được response, thì một website bất kỳ có thể đọc dữ liệu cá nhân của bạn trên mọi website khác mà bạn đang đăng nhập — email, tài khoản ngân hàng, mọi thứ.
Còn việc gửi request sang origin khác thì xưa nay vẫn được phép, vì web cần nó để hoạt động: thẻ <img>, form, script tải từ CDN đều là request cross-origin. Thứ duy nhất bị chặn là việc đọc response.
CORS
Same-origin policy bảo vệ người dùng rất tốt, nhưng cũng rất chặt. Trong thực tế, frontend ở origin này thường xuyên cần đọc dữ liệu từ API ở origin khác. CORS ra đời để mở ngoại lệ cho những trường hợp như vậy — một cách có kiểm soát.
CORS (Cross-Origin Resource Sharing) là cơ chế, định nghĩa trong Fetch Standard, cho phép server báo với browser những origin nào được đọc response của nó. Và bạn sẽ thấy nguyên lý extensibility ở đầu bài phát huy tác dụng: CORS hoạt động hoàn toàn bằng vài HTTP header mới.
Sơ đồ dưới đây tóm tắt toàn bộ cách browser xử lý một request cross-origin. Các mục tiếp theo sẽ giải thích từng bước.
flowchart TD
A["Trang gọi sang origin khác"] --> B{"Simple request?"}
B -->|có| C["Gửi request kèm Origin"]
B -->|không| D["Gửi preflight OPTIONS"]
D --> E{"Server cho phép<br/>method và header?"}
E -->|không| F["Dừng: không gửi<br/>request thật"]
E -->|có| C
C --> G{"Response cho phép<br/>origin này?"}
G -->|có| H["JavaScript đọc được response"]
G -->|không| X["JavaScript không<br/>đọc được response"]
classDef main fill:#dbeafe,stroke:#1e40af,color:#1e3a8a
classDef infra fill:#f4f4f5,stroke:#52525b,color:#27272a
classDef ok fill:#dcfce7,stroke:#166534,color:#14532d
classDef bad fill:#fee2e2,stroke:#991b1b,color:#7f1d1d
class B,E,G main
class A,C,D infra
class H ok
class F,X bad
CORS là gì và không phải là gì
Trước khi đi vào chi tiết, có một điều cần nói rõ: CORS chỉ có thể nới lỏng same-origin policy, chứ không siết thêm được. Server không trả header CORS nào thì browser áp dụng mức chặn mặc định — tức là chặt nhất.
Từ đây rút ra hai điều mà rất nhiều developer hiểu sai:
- CORS bảo vệ user, không bảo vệ server. Nó ngăn website lạ đọc dữ liệu mà browser lấy về bằng cookie của user.
- Chỉ browser mới áp dụng CORS.
curl, Postman hay một service backend khác đều bỏ qua CORS hoàn toàn. Vì vậy, đừng bao giờ coi CORS là lớp bảo mật cho API. Bảo vệ API là việc của authentication và authorization.
Simple request
Có những request cross-origin mà form HTML đã gửi được từ trước khi CORS ra đời. Vì web vốn đã cho phép chúng, browser gửi thẳng những request này mà không hỏi trước. Chúng được gọi là simple request.
Một request là simple khi thoả cả ba điều kiện:
- Method là
GET,HEADhoặcPOST. - Chỉ dùng các header trong danh sách safelisted, như
AccepthayContent-Language. - Nếu có body thì
Content-Typelàapplication/x-www-form-urlencoded,multipart/form-datahoặctext/plain.
Với simple request, browser thêm header Origin, gửi đi, rồi mới kiểm tra header CORS trong response. Bạn cần để ý điểm này: nếu không được phép, JavaScript không đọc được response — nhưng server thì đã nhận và xử lý request rồi.
Preflight request
Các request còn lại có thể làm những việc mà form HTML không bao giờ làm được, như gửi JSON hay gửi header tự đặt. Với những request này, browser cẩn thận hơn: nó hỏi server trước khi gửi. Request hỏi trước đó gọi là preflight.
Preflight là một request OPTIONS, báo cho server biết request thật sẽ dùng method và header nào. Server trả lời có cho phép hay không. Chỉ khi được cho phép, browser mới gửi request thật.
Trong thực tế, phần lớn API call đều cần preflight, vì chỉ cần một trong các điều sau:
- Body là JSON (
Content-Type: application/json). - Có header
Authorizationhoặc header tự đặt khác. - Method là
PUT,PATCHhoặcDELETE.
sequenceDiagram
participant B as Browser (trang trên app.shop.test)
participant S as Server (api.shop.test)
B->>S: OPTIONS /orders/9001<br/>Origin: https://app.shop.test<br/>Access-Control-Request-Method: PUT<br/>Access-Control-Request-Headers: content-type
S-->>B: 204 No Content<br/>Access-Control-Allow-Origin: https://app.shop.test<br/>Access-Control-Allow-Methods: PUT<br/>Access-Control-Allow-Headers: content-type
Note over B: Server đã cho phép
B->>S: PUT /orders/9001
S-->>B: 200 OK<br/>Access-Control-Allow-Origin: https://app.shop.test
Nếu preflight bị từ chối, request thật sẽ không bao giờ được gửi đi.
| Simple request | Preflighted request | |
|---|---|---|
| Khi nào | Request mà form HTML gửi được | Các trường hợp còn lại |
| Số lần gọi server | 1 | 2 (còn 1 nếu kết quả preflight đã được cache) |
| Nếu không được phép | Server vẫn xử lý, JavaScript không đọc được | Request thật không được gửi |
Các header CORS
| Header | Ai gửi | Ý nghĩa |
|---|---|---|
Origin | Browser | Origin của trang đang gọi |
Access-Control-Request-Method | Browser, trong preflight | Method của request thật |
Access-Control-Request-Headers | Browser, trong preflight | Các header ngoài danh sách safelisted mà request thật dùng |
Access-Control-Allow-Origin | Server | Origin được phép đọc response, hoặc * cho mọi origin |
Access-Control-Allow-Methods | Server, trong preflight | Các method được phép |
Access-Control-Allow-Headers | Server, trong preflight | Các request header được phép |
Access-Control-Allow-Credentials | Server | Cho phép đọc response khi request có kèm cookie |
Access-Control-Expose-Headers | Server | Response header nào JavaScript được đọc thêm |
Access-Control-Max-Age | Server, trong preflight | Browser được cache kết quả preflight bao nhiêu giây |
Credential
Trong CORS, credential là những thứ browser tự động gắn vào request: cookie, HTTP authentication và client certificate. Cần phân biệt rõ: token mà JavaScript tự đặt vào header Authorization thì không tính là credential theo nghĩa này.
Vì credential mở đường tới dữ liệu riêng của user, các quy tắc cho nó chặt hơn hẳn:
- Trang phải chủ động bật, ví dụ
fetch(url, { credentials: "include" }). - Server phải trả kèm
Access-Control-Allow-Credentials: true. Access-Control-Allow-Originphải ghi đúng một origin cụ thể, không được dùng*.
Một câu hỏi hay gặp là “dùng * có sao không?”. Câu trả lời phụ thuộc vào cách API xác định user:
| API xác định user bằng | Dùng Access-Control-Allow-Origin: * được không? |
|---|---|
| Không cần (dữ liệu public) | Được |
Token trong header Authorization | Được |
| Cookie | Không — phải ghi đúng origin |
Cho phép nhiều origin
Access-Control-Allow-Origin chỉ chứa được một origin. Vậy nếu có nhiều frontend thì sao? Server giữ một danh sách origin được phép. Mỗi request tới, server so header Origin với danh sách, khớp thì trả lại đúng origin đó.
Lúc này response thay đổi theo header Origin của request. Nhớ lại phần cache: response thay đổi theo request header thì phải khai báo Vary: Origin. Nếu quên, CDN có thể lưu response dành cho origin A rồi trả nhầm cho origin B — một lỗi rất khó debug.
Cấu hình CORS trên server
Đến đây, các quy tắc ở trên có thể chuyển thẳng thành code. Ví dụ dưới dùng Node.js thuần, không framework, để bạn thấy rõ từng header.
import { createServer } from "node:http";
const ALLOWED_ORIGINS = new Set([
"https://app.shop.test",
"http://localhost:5173",
]);
createServer((req, res) => {
const origin = req.headers.origin;
// The answer depends on the Origin header, so caches must keep them apart.
res.setHeader("Vary", "Origin");
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
// Needed only if the API identifies users with cookies.
res.setHeader("Access-Control-Allow-Credentials", "true");
}
// Preflight: answer before any authentication, without a body.
if (
req.method === "OPTIONS" &&
req.headers["access-control-request-method"]
) {
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
);
res.setHeader("Access-Control-Max-Age", "600");
res.writeHead(204).end();
return;
}
// Authentication and the real request handling go here.
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
}).listen(8080);
Mỗi đoạn trong code ứng với một quy tắc đã học:
| Trong code | Vì sao |
|---|---|
Vary: Origin | Response thay đổi theo request header thì phải khai báo cho cache |
| Danh sách origin so khớp chính xác | Khi dùng cookie, Allow-Origin phải là một origin cụ thể |
| Origin lạ thì không trả header CORS | Không có header, browser giữ nguyên mức chặn mặc định |
| Xử lý preflight trước bước authentication | Browser không bao giờ gửi cookie hay token kèm preflight |
Access-Control-Max-Age | Browser không phải preflight lại cho mỗi request |
Đọc một lỗi CORS
Khi gặp lỗi CORS, hãy nhớ rằng lỗi do browser báo, không phải server. Mở tab Network của browser, xem request tương ứng, thường bạn sẽ thấy ngay nguyên nhân:
| Tab Network cho thấy | Nguyên nhân |
|---|---|
Request OPTIONS bị 401 hoặc 403 | Preflight bị chặn ở bước authentication, trong khi nó không mang cookie hay token |
OPTIONS thành công nhưng request thật không được gửi | Method hoặc header chưa có trong Allow-Methods / Allow-Headers |
Response không có Access-Control-Allow-Origin | Origin chưa được cho phép, hoặc route này không trả header CORS |
Access-Control-Allow-Origin: * trong khi dùng cookie | Dùng cookie thì phải ghi đúng origin |
Tóm tắt
- HTTP dựa trên sáu nguyên lý: client–server, request–response, stateless, self-descriptive message, intermediary và extensibility. Các phần khác của giao thức đều xuất phát từ đây.
- Message gồm start line, header, một dòng trống và body (có thể không có). Header mô tả message để mọi bên trên đường đi đều hiểu được.
- Với method, điều cần nhớ là hai tính chất: safe nghĩa là chỉ đọc, idempotent nghĩa là gọi lại bao nhiêu lần cũng cho cùng kết quả trên server.
- Status code cho client biết bước tiếp theo:
4xxlà sửa request,5xxlà lỗi server và có thể retry sau. - Cache dựa vào freshness (
Cache-Control) và validation (ETag,304).Varygiúp cache không trả nhầm representation. - HTTP/1.1, HTTP/2 và HTTP/3 có cùng ý nghĩa, chỉ khác cách truyền message qua mạng.
- Same-origin policy là quy tắc của browser: chặn việc đọc response cross-origin, không chặn việc gửi request.
- CORS cho phép server mở ngoại lệ cho những origin được chọn. Nó bảo vệ user chứ không bảo vệ server, và chỉ browser mới áp dụng nó.