Cookie, session & CORS — vì sao request bị chặn
Giải thích cơ chế cookie (HttpOnly, Secure, SameSite Lax/Strict/None chống CSRF), session server-side vs JWT stateless, same-origin policy, và CORS preflight OPTIONS — bao gồm vì sao browser chặn cross-origin nhưng server vẫn nhận được request.
TL;DR: Browser chặn cross-origin request vì same-origin policy — cơ chế bảo mật ngăn JavaScript trên evil.com đọc dữ liệu từ bank.com. CORS (Cross-Origin Resource Sharing) là cơ chế cho phép server nới lỏng giới hạn đó có chọn lọc. Khi bạn thấy lỗi CORS trong DevTools, đừng nhầm: server đã nhận được request (thậm chí đã xử lý!) — chỉ là browser từ chối đưa response về cho JavaScript. Cookie là cơ chế lưu trạng thái trên browser, gắn với ba attribute bảo mật quan trọng: HttpOnly (chống XSS đọc cookie), Secure (chỉ gửi qua HTTPS), SameSite (kiểm soát cross-site cookie, chống CSRF). Session vs JWT là đánh đổi giữa stateful (server lưu session) và stateless (client mang token).
Bạn đã thấy trong bài 01 rằng HTTP stateless — không có bộ nhớ giữa các request. Vậy login ở trang web hoạt động ra sao? Và tại sao đôi khi fetch() từ React app lại bị browser chặn với lỗi CORS? Bài này trả lời cả hai câu hỏi đó.
1. Analogy — Huy hiệu thành viên và quy tắc cửa hàng
Cookie giống huy hiệu thành viên mà cửa hàng (server) trao cho bạn khi đăng ký:
- Cửa hàng in huy hiệu và trao cho bạn — server gửi
Set-Cookieresponse header. - Mỗi lần bạn vào cửa hàng, bạn đeo huy hiệu — browser tự đính kèm
Cookierequest header. - Huy hiệu có thể có ghi chú: "Chỉ dùng tại chi nhánh X" (Domain), "Hết hạn ngày Y" (Expires), "Chỉ nhân viên nhìn thấy" (HttpOnly — JavaScript không đọc được).
Same-origin policy giống quy tắc của trung tâm thương mại: huy hiệu thành viên cửa hàng A không được dùng tại cửa hàng B (khác origin không được đọc cookie của nhau). CORS giống cửa hàng B đặt bảng "Khách hàng của cửa hàng A được chào đón" — server B cho phép explicitly.
| Huy hiệu / cửa hàng | Cookie / Web |
|---|---|
| Cửa hàng trao huy hiệu | Server gửi Set-Cookie header |
| Bạn đeo huy hiệu mỗi lần vào | Browser gửi Cookie header mỗi request |
| Ghi chú "chỉ nhân viên thấy" | HttpOnly — JavaScript không đọc được |
| Quy tắc: huy hiệu A không dùng ở B | Same-origin policy |
| Bảng "Khách A được chào đón" | CORS Access-Control-Allow-Origin |
Cookie là cách HTTP vượt qua giới hạn stateless. Same-origin policy bảo vệ cookie đó. CORS là van xả có kiểm soát — server quyết định ai được cross-origin.
2. Cookie — cơ chế và attributes bảo mật
2.1 Cách cookie hoạt động
Server tạo cookie bằng Set-Cookie response header:
HTTP/1.1 200 OK
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
Browser lưu cookie và tự đính kèm vào mọi request tiếp theo tới cùng domain:
GET /api/profile HTTP/1.1
Host: api.example.com
Cookie: session_id=abc123
Đây là cách duy nhất HTTP stateless vẫn "nhớ" người dùng — không phải HTTP nhớ, mà browser nhớ và gửi lại.
2.2 Ba attribute bảo mật quan trọng
HttpOnly — ngăn JavaScript đọc cookie:
Set-Cookie: session_id=abc123; HttpOnly
Cookie có HttpOnly không thể đọc bằng document.cookie hay fetch(). Điều này bảo vệ chống XSS (Cross-Site Scripting) — kể cả khi attacker chèn được JavaScript vào trang, chúng không thể đánh cắp session cookie.
Secure — chỉ gửi qua HTTPS:
Set-Cookie: session_id=abc123; Secure
Browser không gửi cookie có Secure qua kết nối HTTP thường — chỉ HTTPS. Bảo vệ chống network eavesdropping (nghe lén trên mạng).
SameSite — kiểm soát cross-site cookie gửi hay không, chống CSRF:
CSRF (Cross-Site Request Forgery) là tấn công khiến browser của nạn nhân gửi request đến bank.com mà nạn nhân không hay biết. Attacker tạo trang evil.com với form ẩn submit POST bank.com/transfer. Nếu nạn nhân đang logged in với cookie session, browser tự đính kèm cookie → bank thực hiện chuyển tiền.
SameSite giải quyết bằng cách kiểm soát khi nào browser gửi cookie theo cross-site request:
| SameSite | Cookie được gửi khi | Mức độ bảo vệ CSRF |
|---|---|---|
Strict | Chỉ same-site request (cùng domain hoàn toàn) | Cao nhất — link từ email cũng không mang cookie |
Lax | Same-site + cross-site navigation GET (click link) | Tốt — chặn form POST cross-site; cho phép link thông thường |
None | Mọi cross-site request | Không — cần kết hợp Secure bắt buộc |
Lax là default từ Chrome 80 (2020). Dùng None khi cần gửi cookie cross-site (ví dụ: third-party widget embed trên nhiều trang) — bắt buộc kết hợp Secure.
SameSite=Lax chặn cross-site POST nhưng cho phép cross-site GET navigation (click link). Nếu có endpoint GET với side effect, Lax không bảo vệ được — nhưng đây cũng là lý do GET phải safe (không có side effect, bài 01). Defense in depth: SameSite=Lax + CSRF token + endpoint GET không có side effect.
3. Session vs JWT — hai cách implement auth trên HTTP stateless
3.1 Session (server-side stateful)
sequenceDiagram
participant B as Browser
participant S as Server
participant DB as Session Store (Redis)
B->>S: POST /login {username, password}
S->>DB: Luu session: {id: "abc123", userId: 42, expires: ...}
S-->>B: 200 OK + Set-Cookie: session_id=abc123; HttpOnly
B->>S: GET /api/profile + Cookie: session_id=abc123
S->>DB: Lookup session "abc123" -> userId=42
S-->>B: 200 OK + {name: "Nguyen Van A"}Server lưu session data trong session store (thường là Redis). Cookie chỉ chứa ID ngẫu nhiên, không có thông tin user. Revoke session: xoá khỏi Redis — hiệu lực ngay lập tức.
3.2 JWT (client-side stateless)
JWT (JSON Web Token) là token tự chứa thông tin (claims) và được ký bằng private key. Server không cần lưu gì — chỉ verify signature:
sequenceDiagram
participant B as Browser
participant S as Server
B->>S: POST /login {username, password}
Note over S: Tao JWT: header.payload.signature
S-->>B: 200 OK + {token: "eyJhbG..."}
B->>S: GET /api/profile + Authorization: Bearer eyJhbG...
Note over S: Verify signature -> decode claims -> userId=42
S-->>B: 200 OK + {name: "Nguyen Van A"}Token được lưu ở client (localStorage hoặc cookie). Decode payload thấy claims như userId, role, exp.
3.3 Đánh đổi session vs JWT
| Tiêu chí | Session | JWT |
|---|---|---|
| Revoke ngay lập tức | Có — xoá Redis | Không — phải chờ token expire |
| Scale ngang | Cần shared session store (Redis) | Không cần shared state |
| Kích thước | Cookie nhỏ (chỉ ID) | Token lớn hơn (mang claims) |
| Server memory | Tốn (lưu session) | Không tốn |
| Complexity | Cần Redis/session store | Cần quản lý key rotation |
Session phù hợp khi cần revoke ngay (banking, enterprise). JWT phù hợp khi cần stateless/microservices — nhưng phải chấp nhận không revoke được trước khi hết hạn (trừ khi dùng blacklist, tức là bạn vừa thêm stateful trở lại).
4. Same-origin policy — nền tảng bảo mật web
4.1 Origin là gì?
Origin (RFC 6454) = scheme + host + port. Ba thành phần phải khớp cả ba mới là same-origin:
| URL | So với https://example.com | Same-origin? |
|---|---|---|
https://example.com/api/users | Cùng scheme, host, port (443) | Có |
http://example.com | Khác scheme (http vs https) | Không |
https://api.example.com | Khác host (subdomain) | Không |
https://example.com:8080 | Khác port | Không |
4.2 Same-origin policy ngăn gì?
JavaScript chạy trên https://evil.com không được dùng fetch() để đọc response từ https://bank.com/api/balance — dù request có bay đi và server xử lý. Browser chặn JavaScript đọc response cross-origin.
Điều này bảo vệ: nếu bạn đang logged in bank.com và vô tình mở evil.com, JavaScript trên evil.com không thể đọc dữ liệu tài khoản từ bank.com — dù nó có thể gửi request (và browser tự đính kèm cookie — đây là lý do CSRF tồn tại).
Đây là điểm hay nhầm: same-origin policy không ngăn browser gửi request cross-origin — nó ngăn JavaScript đọc response. Khi bạn thấy lỗi CORS trong console, request có thể đã tới server và server đã xử lý. Browser chỉ không trả response về cho JavaScript. Hệ quả bảo mật: endpoint có side effect (DELETE, transfer tiền) vẫn bị gọi dù JavaScript không đọc được kết quả — đây là lý do cần CSRF protection riêng.
5. CORS — cross-origin có kiểm soát
CORS (Cross-Origin Resource Sharing) là cơ chế server dùng để nói với browser: "Tôi cho phép JavaScript từ origin X đọc response của tôi."
5.1 Simple request vs Preflight
Không phải mọi cross-origin request đều cần preflight. Simple request (GET/POST với Content-Type thông thường, không có custom header) được gửi thẳng — browser chỉ kiểm tra CORS header trong response:
sequenceDiagram
participant J as JavaScript (evil.com)
participant B as Browser
participant S as api.example.com
J->>B: fetch("https://api.example.com/data")
B->>S: GET /data + Origin: https://evil.com
S-->>B: 200 OK + Access-Control-Allow-Origin: https://myapp.com
Note over B: evil.com KHONG co trong ACAO -> block response
B-->>J: TypeError: Failed to fetchPreflight xảy ra khi request có custom header, hoặc method không phải GET/POST, hoặc Content-Type là application/json. Browser tự động gửi request OPTIONS trước để hỏi server:
sequenceDiagram
participant J as JavaScript (myapp.com)
participant B as Browser
participant S as api.example.com
J->>B: fetch với PUT + Authorization header
B->>S: OPTIONS /api/data<br/>Origin: https://myapp.com<br/>Access-Control-Request-Method: PUT<br/>Access-Control-Request-Headers: Authorization
S-->>B: 200 OK<br/>Access-Control-Allow-Origin: https://myapp.com<br/>Access-Control-Allow-Methods: GET, PUT<br/>Access-Control-Allow-Headers: Authorization<br/>Access-Control-Max-Age: 86400
Note over B: Preflight thanh cong -> gui request that
B->>S: PUT /api/data + Authorization: Bearer ...
S-->>B: 200 OK + Access-Control-Allow-Origin: https://myapp.com
B-->>J: Response data5.2 Các CORS header quan trọng
Response headers (server gửi):
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Access-Control-Allow-Origin: origin nào được phép. Có thể là một origin cụ thể hoặc*(wildcard — tất cả).Access-Control-Allow-Credentials: có cho phép gửi cookie/auth kèm request không.Access-Control-Max-Age: cache preflight result bao nhiêu giây (tránh gửi OPTIONS mỗi request).
5.3 Vì sao Allow-Origin: * + credentials bị cấm
Đây là rule quan trọng trong CORS spec: không thể kết hợp Access-Control-Allow-Origin: * với Access-Control-Allow-Credentials: true.
Lý do: nếu wildcard origin được phép gửi cookie, bất kỳ website nào cũng có thể đọc dữ liệu của bạn từ bank.com kèm session cookie — về cơ bản là xoá bỏ toàn bộ bảo vệ same-origin. Browser từ chối enforce combination này. Khi cần credentials (cookie, Authorization), phải set origin cụ thể.
6. Pitfall — lỗi thường gặp
❌ Nhầm 1: "Lỗi CORS là lỗi server — server không nhận được request." ✅ Sai. CORS check xảy ra ở browser khi đọc response. Server thường đã nhận request và xử lý xong — chỉ là không set CORS header đúng trong response. Hệ quả: nếu request là DELETE hay POST có side effect, server đã thực thi dù browser block response. Fix: thêm CORS header vào response, không phải "fix server nhận request".
❌ Nhầm 2: Set Access-Control-Allow-Origin: * và Access-Control-Allow-Credentials: true cùng lúc.
✅ Browser từ chối combination này — lỗi sẽ là "credential flag is 'true' but the 'Access-Control-Allow-Origin' header is '*'". Phải liệt kê origin cụ thể: Access-Control-Allow-Origin: https://myapp.com khi cần credentials.
❌ Nhầm 3: Cookie không có HttpOnly — lưu session token trong document.cookie có thể đọc bằng JavaScript.
✅ Bất kỳ XSS nào (kể cả từ script inject qua third-party) đều đọc được document.cookie nếu thiếu HttpOnly. Session cookie phải luôn có HttpOnly. JWT nếu lưu trong cookie cũng nên HttpOnly — nếu lưu trong localStorage thì không được bảo vệ bởi HttpOnly.
7. Liên hệ các bài khác
- Bài 01 — HTTP anatomy: 401 vs 403 liên quan trực tiếp — 401 khi thiếu/sai credentials, 403 khi đủ quyền. Method OPTIONS là gì — bài này giải thích trong context CORS preflight.
- Bài 02 — HTTP headers: cookie gửi qua
Cookierequest header vàSet-Cookieresponse header — là header đặc biệt nhất trong HTTP. - Bài 06 — REST, gRPC, WebSocket, SSE: WebSocket handshake ban đầu là HTTP GET với
Upgradeheader — cũng chịu CORS rule. - Module 1 — TCP handshake: CORS preflight là một HTTP round trip — mỗi OPTIONS request tốn 1 RTT.
Access-Control-Max-Agecache preflight để tránh tốn RTT mỗi request.
8. 📚 Deep Dive — tài liệu gốc
Đọc khi muốn hiểu tận gốc:
- Fetch Specification — CORS — spec chính thức của CORS (WHATWG), định nghĩa chính xác simple request, preflight, response check. Đây là gì browser implement, không phải RFC.
- RFC 6265 — HTTP State Management (Cookie) — định nghĩa
Set-Cookie,Cookieheader, các attribute cơ bản. RFC 6265bis (draft) bổ sungSameSite. - RFC 6454 — The Web Origin Concept — định nghĩa chính thức "origin" là gì và same-origin policy.
Ghi chú: CORS spec trong Fetch spec (WHATWG) là nguồn chuẩn nhất, không phải RFC riêng. RFC 6265 là nơi tra cứu khi cần hiểu tường tận Cookie attribute. Cả hai dài — dùng để tra cứu, không đọc từ đầu.
9. Tóm tắt
- Cookie là cách HTTP stateless lưu trạng thái: server gửi
Set-Cookie, browser lưu và tự gửi kèmCookieheader mỗi request cùng domain. HttpOnlyngăn JavaScript đọc cookie — bảo vệ khỏi XSS.Securechỉ gửi qua HTTPS.SameSite=Laxngăn gửi cookie theo cross-site POST — chống CSRF.SameSite=Nonephải kèmSecure; kết hợpAccess-Control-Allow-Origin: *+credentials: truebị spec cấm.- Session: server lưu state (Redis), cookie chứa ID — revoke ngay lập tức. JWT: stateless, client mang token — không revoke trước expire trừ khi có blacklist.
- Same-origin policy: JavaScript không đọc được response cross-origin. Origin = scheme + host + port — cả ba phải khớp.
- Same-origin policy không ngăn request gửi đi — chỉ ngăn JavaScript đọc response. Lỗi CORS xảy ra ở browser, không phải server.
- CORS preflight: browser tự gửi OPTIONS hỏi server; request "không simple" (custom header, method PUT/DELETE, Content-Type là JSON) đều trigger preflight.
Access-Control-Max-Agecache preflight để tránh OPTIONS mỗi request.
10. Tự kiểm tra
Q1Tại sao lỗi CORS xuất hiện trong console nhưng server log vẫn ghi nhận request?▸
Origin header), server xử lý và trả response. Sau đó browser kiểm tra response có Access-Control-Allow-Origin không và origin có khớp không. Nếu không khớp, browser block JavaScript đọc response và throw CORS error — nhưng request đã xảy ra, server đã log. Hệ quả quan trọng: endpoint có side effect (DELETE, tạo bản ghi) có thể đã thực thi dù client thấy lỗi CORS.Q2HttpOnly và SameSite bảo vệ khỏi loại tấn công nào? Hai loại tấn công đó khác nhau thế nào?▸
HttpOnly bảo vệ khỏi XSS (Cross-Site Scripting): attacker chèn script vào trang để đọc document.cookie. HttpOnly làm cookie vô hình với JavaScript. SameSite bảo vệ khỏi CSRF (Cross-Site Request Forgery): attacker lừa browser nạn nhân gửi request đến server hợp lệ kèm cookie. Hai tấn công khác hướng: XSS là đọc dữ liệu client-side; CSRF là lợi dụng cookie tự động gửi để thực hiện action thay mặt nạn nhân. Cần cả hai attribute để bảo vệ toàn diện.Q3Khi nào browser gửi CORS preflight (OPTIONS)? Khi nào không cần?▸
Content-Type không phải application/x-www-form-urlencoded, multipart/form-data, hay text/plain; hoặc có custom header (như Authorization, X-Custom-Header). Ví dụ: fetch(url, {method: "PUT", headers: {"Content-Type": "application/json"}}) trigger preflight vì cả method PUT lẫn Content-Type JSON đều là non-simple. Simple request (GET với header cơ bản) không cần preflight — browser gửi thẳng và kiểm tra CORS header trong response.Q4Vì sao không thể set Access-Control-Allow-Origin: * kèm Access-Control-Allow-Credentials: true?▸
*) nghĩa là bất kỳ website nào cũng được phép đọc response. Nếu kết hợp với credentials: true (gửi cookie/auth), thì bất kỳ website nào cũng có thể gọi API với cookie của user — ví dụ evil.com gọi bank.com/api/balance kèm session cookie của nạn nhân và đọc số dư. Điều này phá vỡ hoàn toàn bảo vệ same-origin. Spec (Fetch standard) cấm browser accept combination này. Khi cần credentials, phải liệt kê origin cụ thể để server chịu trách nhiệm whitelist.Q5Session và JWT có tradeoff gì khi cần revoke quyền truy cập ngay lập tức?▸
Q6SameSite=Lax bảo vệ CSRF thế nào? Có lỗ hổng gì không?▸
SameSite=Lax ngăn browser gửi cookie theo cross-site POST/PUT/DELETE request — loại trừ được phần lớn CSRF attack vì attacker phải tạo form POST từ domain khác. Browser vẫn gửi cookie khi user click link (cross-site GET navigation) — phù hợp để user click link trên mạng xã hội vào trang logged in. Lỗ hổng: nếu endpoint GET có side effect (vi phạm safe method convention), Lax không bảo vệ được — attacker tạo link https://bank.com/transfer?to=evil&amount=1000 và lừa user click. Defense in depth: SameSite=Lax kết hợp với CSRF token và GET không có side effect.Q7Bạn build SPA (React) ở https://myapp.com gọi API ở https://api.myapp.com. Cần làm gì để CORS hoạt động với cookie auth?▸
Vì subdomain khác nhau (myapp.com vs api.myapp.com) là khác origin, cần:
- Server: set
Access-Control-Allow-Origin: https://myapp.com(không dùng wildcard vì cần credentials),Access-Control-Allow-Credentials: true, vàAccess-Control-Allow-Headers: Content-Type, Authorizationnếu dùng. - Client (fetch): thêm
credentials: "include"để browser gửi cookie cross-origin. - Cookie: set
SameSite=None; Securevì cross-site.SameSite=Laxsẽ chặn cookie gửi đi. - Preflight: set
Access-Control-Max-Ageđể cache OPTIONS response, tránh preflight mỗi request.
Bài tiếp theo: HTTP/2 — multiplexing và stream
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên