Session cookie hay JWT: chọn cơ chế xác thực cho backend Trung cấp
Thời gian đọc ước tính: 14 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một shop có web app và mobile app. Team mới chọn JWT “vì stateless, dễ scale”, lưu access token 7 ngày trong localStorage. Ba tháng sau, một nhân viên nghỉ việc và bị khoá tài khoản, nhưng token của họ vẫn gọi được API thêm 7 ngày. Không có cách nào thu hồi, vì server không lưu gì.
Câu hỏi đúng không phải “session hay JWT cái nào hiện đại hơn”, mà là: trạng thái đăng nhập nằm ở đâu, và bạn cần thu hồi nhanh tới mức nào?
Tóm tắt nhanh
Phần tiêu đề “Tóm tắt nhanh”| Tiêu chí | Server-side session (cookie chứa ID) | JWT tự chứa (self-contained) |
|---|---|---|
| Client giữ gì | ID ngẫu nhiên, không mang ý nghĩa | Claims đã ký (sub, exp, aud…) |
| Server giữ gì | Bản ghi session (DB, Redis) | Chỉ key để verify |
| Thu hồi | Xoá bản ghi → có hiệu lực ở request kế tiếp | Không thu hồi được trước exp, trừ khi thêm trạng thái (denylist, session version) |
| Kích thước | Vài chục byte | Vài trăm byte trở lên; demo bên dưới: 263 byte với 7 claim |
| Mỗi request | Một lookup tới session store | Verify chữ ký cục bộ, không I/O |
| Scale ngang | Cần store dùng chung (hoặc sticky session) | Mọi instance có key là verify được |
| Đọc được nội dung? | Không, chỉ là ID | Có: payload chỉ base64url, không mã hoá |
| Phơi nhiễm XSS | Thấp nếu cookie HttpOnly |
Cao nếu lưu ở localStorage |
| Phơi nhiễm CSRF | Có (browser tự gắn cookie) | Không nếu gửi qua header Authorization; có nếu đặt trong cookie |
Hai kết luận rút ra:
- Với web app của chính bạn (cùng site), session cookie là mặc định đơn giản và an toàn hơn. OWASP lưu ý: nếu dùng JWT làm session thì phải có cách invalidate nó. Mà đã cần denylist hay lookup thì JWT không còn “stateless” nữa, nên với web app một backend, session thường đơn giản hơn.
- JWT hợp lý khi nhiều service/bên thứ ba cần verify mà không gọi về auth server: access token OAuth, token giữa service, API cho mobile.
CSRF và SameSite đã được phân tích riêng ở bài CORS và CSRF; bài này chỉ nhắc khi cần so sánh.
Server-side session làm đúng
Phần tiêu đề “Server-side session làm đúng”Session ID chỉ là con trỏ tới dữ liệu trên server. Toàn bộ an toàn nằm ở ID và cookie chứa nó.
| Yêu cầu (OWASP Session Management) | Cụ thể |
|---|---|
| Entropy của ID | Tối thiểu 64 bit, sinh bằng CSPRNG. Thực tế: randomBytes(32) cho thoải mái |
| Đổi ID sau đăng nhập | Bắt buộc regenerate sau mọi thay đổi quyền (login, nâng quyền) để chặn session fixation |
| Timeout do server quyết định | Idle: 2–5 phút cho app giá trị cao, 15–30 phút cho app rủi ro thấp. Absolute: 4–8 giờ cho app dùng cả ngày làm việc |
| Logout | Phải invalidate ở server; chỉ xoá cookie phía client là chưa đủ |
Cookie attributes
Phần tiêu đề “Cookie attributes”Set-Cookie: __Host-sid=k3J9...; Path=/; Secure; HttpOnly; SameSite=Lax| Thuộc tính | Tác dụng | Không làm được |
|---|---|---|
HttpOnly |
JavaScript không đọc được qua document.cookie |
Không ngăn browser gắn cookie vào request (kể cả fetch do script gọi), nên không chống CSRF |
Secure |
Chỉ gửi qua https: (trừ localhost) |
Không mã hoá nội dung cookie |
SameSite=Strict |
Không gửi cookie với request cross-site | Người vào từ link ngoài sẽ thấy như chưa đăng nhập |
SameSite=Lax |
Gửi thêm với top-level navigation dùng method an toàn (GET) | Không phải lớp chống CSRF đầy đủ |
Prefix __Host- |
Browser chỉ nhận cookie nếu có Secure, không có Domain, và Path=/ |
Không thay cho HttpOnly hay SameSite |
OWASP yêu cầu khai SameSite tường minh (Strict ưu tiên, hoặc Lax) và khuyến nghị prefix __Host- cho session ID. __Host- khoá cookie vào đúng host: subdomain khác không ghi đè được. SameSite và cookie prefix được định nghĩa trong bản cập nhật RFC 6265bis (vẫn là Internet-Draft), không có trong RFC 6265 gốc.
Lưu token ở đâu trong browser
Phần tiêu đề “Lưu token ở đâu trong browser”| Nơi lưu | XSS đọc được token? | CSRF? | Kết luận |
|---|---|---|---|
localStorage / sessionStorage |
Có, mọi script cùng origin | Không | OWASP: không lưu session ID, JWT, refresh token ở đây |
| Biến trong memory của SPA | Có (script đang chạy cùng context) | Không | Mất khi reload; vẫn lộ trước XSS |
Cookie HttpOnly; Secure; SameSite |
Không đọc được | Có, cần chống CSRF | OWASP khuyến nghị (SameSite=Strict ưu tiên) |
| BFF (backend-for-frontend) giữ token | Không, browser chỉ có session cookie | Có, như session | Tốt nhất cho SPA gọi API có OAuth |
Cần nói rõ giới hạn: HttpOnly chỉ ngăn đánh cắp token. Nếu trang đã dính XSS, script độc vẫn gửi request thay người dùng ngay trong browser đó. Nhưng nó không mang token đi dùng ở nơi khác được, và token không bị lộ sau khi lỗ hổng được vá.
JWT: verifier phải kiểm tra gì
Phần tiêu đề “JWT: verifier phải kiểm tra gì”JWT ký bằng HS256 = base64url(header).base64url(payload).HMAC-SHA256(...). Header nói thuật toán (alg), nhưng header là dữ liệu kẻ tấn công kiểm soát. RFC 8725 tổng kết các lỗi đã xảy ra ngoài thực tế:
| Lỗi | Cơ chế | Cách chặn (RFC 8725) |
|---|---|---|
alg: none |
Kẻ tấn công đổi alg thành none, bỏ chữ ký; thư viện cũ chấp nhận |
§3.1: caller chỉ định allowlist thuật toán; thư viện không được dùng thuật toán ngoài danh sách |
| Algorithm/key confusion | Token đổi RS256 → HS256; thư viện dùng public key RSA làm HMAC secret, mà ai cũng có public key |
§3.1: mỗi key chỉ dùng với đúng một thuật toán |
| Key yếu | HMAC secret là mật khẩu dễ nhớ → brute-force offline khi có một token | §3.5: key đủ entropy. OWASP: secret HS256 tối thiểu 256 bit, sinh ngẫu nhiên |
| Substitution | Token cấp cho service A được dùng ở service B cùng issuer | §3.8–3.9: validate iss; nếu issuer phục vụ nhiều bên thì bắt buộc có và validate aud |
Tin kid, jku trong header |
kid đi vào query (SQL injection), jku trỏ tới URL của kẻ tấn công (SSRF, key giả) |
§3.10: sanitize kid; chỉ lấy key từ nguồn tin cậy cấu hình sẵn |
| Cross-JWT confusion | ID token hay token reset password bị dùng làm access token | §3.11–3.12: typ tường minh (vd at+jwt), quy tắc validate tách biệt |
Thứ tự verify đúng:
- Allowlist
alg(cố định trong code, không đọc từ token để quyết định). - Chọn key theo
kidtrong tập key của issuer mong đợi (JWKS lấy từjwks_uriđã cấu hình), không bao giờ dùng key nhúng trong token. - Verify chữ ký. Sai thì dừng, không đọc claim.
- Kiểm tra
iss,aud,exp,nbf(vàtypnếu dùng). RFC 7519 cho phép leeway nhỏ cho clock skew, “thường không quá vài phút”. - Mới đến authorization:
subcó quyền với object này không (BOLA/IDOR).
Trong production, dùng thư viện được bảo trì (ví dụ jose cho Node) và truyền tường minh algorithms, issuer, audience. Demo dưới đây tự viết chỉ để thấy từng bước.
Đừng đặt bí mật trong claims
Phần tiêu đề “Đừng đặt bí mật trong claims”Payload JWS chỉ là base64url. Ai có token (log, proxy, browser extension) đều đọc được. RFC 7519 §12: cách đơn giản nhất để giảm rủi ro riêng tư là không đưa thông tin nhạy cảm vào JWT. Cần giấu thì để dữ liệu ở server, hoặc dùng JWE (mã hoá), nhưng đó là thêm độ phức tạp.
Xoay key với kid và JWKS
Phần tiêu đề “Xoay key với kid và JWKS”Issuer công bố public key dạng JWK Set, mỗi key có kid. Quy trình xoay key an toàn:
- Thêm key mới vào JWKS, chưa dùng để ký.
- Chờ các verifier refresh cache JWKS.
- Chuyển sang ký bằng key mới.
- Giữ key cũ trong JWKS ít nhất bằng thời gian sống tối đa của token đã ký bằng nó, rồi gỡ.
Với HS256 không có public key: mọi verifier giữ cùng một secret, nên ai verify được cũng ký được. Có từ hai service trở lên cần verify, chuyển sang thuật toán bất đối xứng (ES256, RS256) để chỉ auth server giữ private key.
Demo: HS256 bằng node:crypto
Phần tiêu đề “Demo: HS256 bằng node:crypto”Không thư viện, không service ngoài. Clock cố định ở now = 1800000000, access token sống 300 giây, leeway 30 giây.
npx --prefix examples ts-node examples/src/session-vs-jwt/jwt-demo.tsOutput thật (phần đầu token thay đổi mỗi lần chạy vì secret ngẫu nhiên):
token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ... (263 bytes)payload (base64url, NOT encrypted) = {"iss":"https://auth.shop.example","aud":"orders-api","sub":"user-42","jti":"a1b2c3","iat":1800000000,"nbf":1800000000,"exp":1800000300}
1. valid token ACCEPT sub=user-422. payload sub -> admin REJECT bad signature3. alg: none, empty signature REJECT alg "none" not allowed4. header alg: HS512 REJECT alg "HS512" not allowed5. same token, 301s later ACCEPT sub=user-426. same token, 331s later REJECT expired (exp)7. aud = billing-api REJECT wrong audĐọc kết quả:
- Dòng 2: sửa payload là chữ ký sai ngay. Chữ ký bảo vệ toàn vẹn, không bảo vệ bí mật (dòng
payloadin ra rõ ràng). - Dòng 3–4: bị chặn ở bước allowlist, trước khi đụng tới chữ ký. Đây là chỗ các lỗ hổng
alg: nonecổ điển xảy ra. - Dòng 5–6: hết hạn 1 giây nhưng vẫn được nhận vì leeway 30 giây. Leeway là một khoảng sống thêm thật sự, giữ nó nhỏ.
- Dòng 7: chữ ký hợp lệ, cùng issuer, nhưng sai audience → từ chối. Đây là substitution attack.
Thu hồi: JWT cần lại trạng thái
Phần tiêu đề “Thu hồi: JWT cần lại trạng thái”Không có chiến lược nào vừa thu hồi tức thì vừa hoàn toàn stateless. Chọn mức trạng thái chấp nhận được:
| Chiến lược | Cách làm | Thu hồi nhanh tới đâu | Cái giá |
|---|---|---|---|
| Access token ngắn hạn | exp vài phút |
Trễ tối đa bằng TTL | Phải có refresh token |
| Refresh token rotation + reuse detection | Mỗi lần refresh cấp RT mới, RT cũ vô hiệu; RT cũ bị dùng lại → thu hồi cả family | Ngay khi refresh kế tiếp | Auth server lưu trạng thái RT; race khi hai tab refresh cùng lúc |
Denylist theo jti |
Lưu (iss, jti) bị thu hồi tới khi exp |
Ngay lập tức | Lookup mỗi request, như session |
| Session version | Token mang sv; user có session_version trong DB/cache; tăng số để “logout mọi thiết bị” |
Ngay lập tức | Lookup mỗi request (cache được) |
OWASP cảnh báo: khoá denylist bằng jti + iss, không bằng hash của cả token. Hash của token có thể bị né qua JWT malleability (parse không chặt, chữ ký ECDSA biến đổi được).
Refresh token rotation và phát hiện reuse
Phần tiêu đề “Refresh token rotation và phát hiện reuse”RFC 9700 §4.14.2 yêu cầu auth server phát hiện replay refresh token của public client (SPA, mobile) bằng sender-constrained token hoặc rotation. Khi RT cũ bị dùng lại, server không biết ai là kẻ tấn công, nên thu hồi RT đang active và bắt đăng nhập lại.
User đăng nhập: nhận access token (AT) ngắn hạn và refresh token RT1. Server lưu hash của RT1, gắn vào family F (một lần đăng nhập).
Mô phỏng in-memory, lưu SHA-256 của RT thay vì RT gốc:
npx --prefix examples ts-node examples/src/session-vs-jwt/refresh-rotation-demo.tst1 user refreshes RT1 OK, new RT eQ4inm...t2 attacker replays RT1 FAIL REUSE detected -> family a57968e5 revokedt3 user refreshes RT2 FAIL family revokedfamily status: revoked, revokedRFC 9700 cũng cho phép thu hồi RT khi đổi mật khẩu hoặc logout, và khuyến nghị RT hết hạn nếu client không hoạt động một thời gian.
Logout thật sự nghĩa là gì
Phần tiêu đề “Logout thật sự nghĩa là gì”| Hành động | Session cookie | JWT + refresh token |
|---|---|---|
| Logout thiết bị này | Xoá bản ghi session + Set-Cookie với Max-Age=0 |
Thu hồi RT family; access token vẫn sống tới exp trừ khi có denylist |
| Logout mọi thiết bị | Xoá mọi session của user | Thu hồi mọi family, hoặc tăng session version |
| Khoá tài khoản, đổi mật khẩu | Như trên | Như trên; TTL access token chính là độ trễ tối đa |
Nếu sản phẩm yêu cầu “khoá là mất quyền ngay”, JWT thuần không đáp ứng được. Hoặc chấp nhận độ trễ bằng TTL, hoặc thêm lookup.
Khi nào dùng session, khi nào dùng JWT
Phần tiêu đề “Khi nào dùng session, khi nào dùng JWT”Đang tải diagram…
| Tình huống | Chọn | Lý do |
|---|---|---|
| Web app server-rendered hoặc SPA cùng site | Session cookie | Thu hồi tức thì, không lộ token cho JS, ít thứ để làm sai |
| SPA dùng OAuth/OIDC với IdP bên ngoài | BFF + session cookie | Token ở server; theo hướng OWASP tránh lưu token trong browser |
| Mobile app gọi API | Access token ngắn hạn + RT rotation | Không có cookie jar kiểu browser; lưu RT ở secure storage của OS |
| Service-to-service, nhiều consumer | JWT ký bất đối xứng, aud riêng mỗi service |
Verify cục bộ, không gọi về auth server |
| Cần “khoá là mất quyền ngay” | Session, hoặc JWT + lookup (denylist/version) | JWT thuần chỉ hết hiệu lực ở exp |
Không nên dùng JWT chỉ để “khỏi cần Redis”. Một lookup Redis mỗi request thường rẻ hơn nhiều so với hậu quả của token không thu hồi được.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Trade-offs
Phần tiêu đề “Trade-offs”JWT tự chứa được gì
- Verify cục bộ, không I/O, không phụ thuộc session store.
- Nhiều service, nhiều tổ chức tin cùng một issuer qua JWKS.
- Mang claims (scope, tenant) đi cùng request.
JWT tự chứa mất gì
- Không thu hồi được trước
expnếu không thêm trạng thái. - Nhiều cách validate sai (
alg,aud,kid), mỗi cách là một lỗ hổng. - Payload đọc được; token to hơn session ID nhiều lần.
- Xoay key và quản lý JWKS là việc vận hành thêm.
Checklist production
Phần tiêu đề “Checklist production”- Session ID ≥ 64 bit entropy từ CSPRNG; regenerate sau login và mọi thay đổi quyền.
- Cookie:
__Host-prefix,Secure,HttpOnly,SameSitekhai tường minh; kèm chống CSRF (CORS và CSRF). - Không lưu session ID, JWT hay refresh token trong
localStorage/sessionStorage. - JWT: allowlist
algtrong code, mỗi key một thuật toán, validateiss/aud/exp/nbf, leeway nhỏ; key lấy từ JWKS của issuer đã cấu hình. - Secret HS256 ≥ 256 bit ngẫu nhiên; có nhiều verifier thì dùng ES256/RS256.
- Access token ngắn hạn (phút); refresh token rotation có reuse detection, lưu hash, hết hạn khi không hoạt động.
- Logout và khoá tài khoản invalidate ở server: xoá session, thu hồi RT family, tăng session version.
- Không đưa dữ liệu nhạy cảm vào claims; không log nguyên token.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao HttpOnly không chống được CSRF? (2) Nếu token có alg: HS256 mà server đang dùng RS256, chuyện gì xảy ra với thư viện cấu hình lỏng? (3) Refresh token rotation phát hiện reuse rồi thì thu hồi token nào, và vì sao không chỉ thu hồi token cũ?