Bỏ qua để đến nội dung
Knowledge Hub

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

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?

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.

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 đủ
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.

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 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:

  1. Allowlist alg (cố định trong code, không đọc từ token để quyết định).
  2. Chọn key theo kid trong 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.
  3. Verify chữ ký. Sai thì dừng, không đọc claim.
  4. Kiểm tra iss, aud, exp, nbf (và typ nếu dùng). RFC 7519 cho phép leeway nhỏ cho clock skew, “thường không quá vài phút”.
  5. Mới đến authorization: sub có 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.

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.

Issuer công bố public key dạng JWK Set, mỗi key có kid. Quy trình xoay key an toàn:

  1. Thêm key mới vào JWKS, chưa dùng để ký.
  2. Chờ các verifier refresh cache JWKS.
  3. Chuyển sang ký bằng key mới.
  4. 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.

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.

Terminal window
npx --prefix examples ts-node examples/src/session-vs-jwt/jwt-demo.ts

Output 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-42
2. payload sub -> admin REJECT bad signature
3. alg: none, empty signature REJECT alg "none" not allowed
4. header alg: HS512 REJECT alg "HS512" not allowed
5. same token, 301s later ACCEPT sub=user-42
6. 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 payload in 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: none cổ đ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.

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).

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.

Refresh token bị đánh cắp: rotation phát hiện reuse và thu hồi cả family
Refresh token bị đánh cắp: rotation phát hiện reuse và thu hồi cả familyBước 1/6: 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).thời gianfamily F còn hiệu lực …Loginnhận AT + RT1RT1 = activefamily FClient thậtapp của user-42Auth serverlưu hash RT theo familyKẻ tấn côngcó bản sao RT1
Bước 1/6

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:

Terminal window
npx --prefix examples ts-node examples/src/session-vs-jwt/refresh-rotation-demo.ts
t1 user refreshes RT1 OK, new RT eQ4inm...
t2 attacker replays RT1 FAIL REUSE detected -> family a57968e5 revoked
t3 user refreshes RT2 FAIL family revoked
family status: revoked, revoked

RFC 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.

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.

Đ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.

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 exp nế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.
  1. Session ID ≥ 64 bit entropy từ CSPRNG; regenerate sau login và mọi thay đổi quyền.
  2. Cookie: __Host- prefix, Secure, HttpOnly, SameSite khai tường minh; kèm chống CSRF (CORS và CSRF).
  3. Không lưu session ID, JWT hay refresh token trong localStorage/sessionStorage.
  4. JWT: allowlist alg trong code, mỗi key một thuật toán, validate iss/aud/exp/nbf, leeway nhỏ; key lấy từ JWKS của issuer đã cấu hình.
  5. Secret HS256 ≥ 256 bit ngẫu nhiên; có nhiều verifier thì dùng ES256/RS256.
  6. 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.
  7. Logout và khoá tài khoản invalidate ở server: xoá session, thu hồi RT family, tăng session version.
  8. Không đưa dữ liệu nhạy cảm vào claims; không log nguyên token.

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ũ?