Mục lục
- Mục Tiêu Bài Học
- Định Nghĩa Nhanh
- Bảng So Sánh Tiêu Chí Kỹ Thuật
- Stateful Session — Recap Nhanh
- JWT — Cấu Trúc & Verify
- JWT Pros — Lý Do Stateless Hấp Dẫn
- JWT Cons — Vấn Đề Thực Tế
- JWT Revocation — Blacklist Pattern
- Hybrid Pattern — JWT Access + Redis Refresh
- Security — Cookie vs localStorage
- Token Theft & Multi-Device Logout
- Update Permission Flow
- Performance & Multi-Region
- Decision Framework
- Anti-Patterns
- Tổng Kết & Quiz
Mục Tiêu Bài Học
- Phân biệt stateful session và stateless JWT ở cấp độ cơ chế (không phải chỉ khái niệm).
- Hiểu trade-off cụ thể: revocability, latency, payload size, horizontal scale.
- Biết khi nào hybrid (JWT access + Redis refresh) phù hợp hơn pure JWT.
- Chọn được cơ chế phù hợp dựa trên kiến trúc (monolith / microservices / mobile / web SPA).
- Nhận diện anti-pattern phổ biến trong triển khai JWT và session.
Định Nghĩa Nhanh
Stateful session: server lưu toàn bộ session state (user ID, role, metadata) trong một store (ở đây là Redis). Client chỉ cầm session ID — một chuỗi opaque không chứa thông tin. Mỗi request, server dùng session ID để tra cứu state từ Redis.
Stateless JWT: server không lưu gì. Client cầm một signed token (JSON Web Token) chứa claims (user ID, role, thời hạn...). Mỗi request, server chỉ verify signature để xác nhận token hợp lệ, không cần I/O.
Điểm mấu chốt: với stateful session, server có thể revoke (thu hồi) bất kỳ lúc nào bằng cách xóa Redis key. Với JWT thuần, token đã issue ra thì có hiệu lực đến hết trường exp, không có cách "cancel" phía server trừ khi dùng thêm blacklist.
Bảng So Sánh Tiêu Chí Kỹ Thuật
| Tiêu chí | Stateful Session (Redis) | Stateless JWT |
|---|---|---|
| Server state | Có (Redis key) | Không |
| I/O mỗi request | 1 Redis GET (~0.5–1ms) | Không (CPU verify) |
| Verify latency | ~0.5–1ms (network Redis) | ~0.05–0.2ms HS256, ~0.5–2ms RS256 |
| Revoke session | Instant (DEL key) |
Không có (cần blacklist) |
| Update permission | Instant (ghi lại session blob) | Phải đợi token expire |
| Payload per request | Nhỏ (cookie = session ID ~32B) | Lớn (JWT ~500B–2KB header) |
| Horizontal scale | Redis là bottleneck (acceptable) | Không state, scale tự nhiên |
| Cross-service | Phải truy cập central Redis | Verify độc lập (shared secret) |
| Token theft mitigation | Revoke + rotate ID ngay | Khó, cần blacklist + short TTL |
| Mobile-friendly | Cần quản lý cookie | Tốt (Authorization header) |
Không có lựa chọn tốt hơn tuyệt đối. Mỗi tiêu chí ưu tiên phụ thuộc vào kiến trúc cụ thể — phần sau đi sâu vào từng vấn đề.
Stateful Session — Recap Nhanh
Bài 76 đã cover chi tiết. Tóm tắt flow để làm nền so sánh:
import redis
import json
import secrets
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
def create_session(user_id: int, role: str) -> str:
session_id = secrets.token_urlsafe(32)
payload = json.dumps({"user_id": user_id, "role": role})
r.set(f"session:{session_id}", payload, ex=3600)
return session_id
def get_session(session_id: str) -> dict | None:
raw = r.get(f"session:{session_id}")
return json.loads(raw) if raw else None
def revoke_session(session_id: str) -> None:
r.delete(f"session:{session_id}") # instant revoke
Điểm quan trọng: revoke_session chỉ cần một DEL. Mọi request sau đó với session ID đó đều trả về None lập tức — không cần đợi.
JWT — Cấu Trúc & Verify
JWT (JSON Web Token, RFC 7519) gồm ba phần nối bằng dấu chấm: Header.Payload.Signature, mỗi phần là Base64URL-encoded.
- Header:
{"alg": "HS256", "typ": "JWT"}— thuật toán ký. - Payload (claims):
{"sub": "1", "role": "admin", "exp": 1748000000, "iat": 1747996400, "jti": "abc-uuid"}. - Signature:
HMAC-SHA256(base64url(header) + "." + base64url(payload), secret). Với RS256 dùng RSA private key ký, public key verify.
Claim conventions theo RFC 7519:
iss(issuer): service issue token.sub(subject): user ID.aud(audience): service expected to receive.exp(expiration): UNIX timestamp hết hạn.iat(issued at): thời điểm tạo.nbf(not before): token chưa có hiệu lực trước thời điểm này.jti(JWT ID): unique ID, dùng để track revocation.
import jwt
import time
SECRET = "your-256-bit-secret" # trong production: đọc từ env, rotate định kỳ
def issue_token(user_id: int, role: str) -> str:
return jwt.encode(
{
"sub": str(user_id),
"role": role,
"iat": int(time.time()),
"exp": int(time.time()) + 900, # 15 phút
"jti": secrets.token_urlsafe(16),
},
SECRET,
algorithm="HS256",
)
def verify_token(token: str) -> dict:
# Raises jwt.ExpiredSignatureError, jwt.InvalidTokenError nếu invalid
return jwt.decode(token, SECRET, algorithms=["HS256"])
Payload không được mã hoá — chỉ được Base64URL-encode. Ai cũng decode được claims. Signature chỉ đảm bảo tính toàn vẹn, không bảo mật nội dung.
JWT Pros — Lý Do Stateless Hấp Dẫn
- Không cần central store: server verify token bằng CPU, không I/O. Thêm server instance không cần cấu hình thêm gì cho authentication.
- Cross-service verify: API gateway issue token, microservice B chỉ cần shared secret (HS256) hoặc public key (RS256) để verify — không cần gọi vào Redis của gateway. Đặc biệt hữu ích trong microservices với nhiều domain.
- Mobile-friendly: token gửi qua
Authorization: Bearer <token>, không phụ thuộc cookie. Phù hợp với native app (iOS, Android) không có cookie jar tự động. - Tự chứa thông tin: claims trong payload tránh DB lookup cho role/permission phổ biến. Ví dụ: middleware check
claims["role"] == "admin"mà không cần query DB.
Những ưu điểm này phù hợp nhất với kiến trúc nhiều service cần verify token độc lập, hoặc khi Redis không nằm trong topology của tất cả services.
JWT Cons — Vấn Đề Thực Tế
1. Không revoke được
Đây là hạn chế căn bản nhất. Token đã issue có hiệu lực đến exp, không có cách nào phía server "cancel" nó:
- User logout → server không có mechanism invalid token. Nếu client vứt token đi thì OK, nhưng nếu token bị lấy trước khi logout, nó vẫn hoạt động.
- Account bị khóa khẩn cấp (phát hiện xâm phạm, admin disable user) → token vẫn valid đến hết
exp. - Token bị đánh cắp qua log leak / proxy → kẻ tấn công dùng được đến hết
exp.
2. Permission lag
User được promote từ user lên admin trong DB. Claims trong token cũ vẫn là "role": "user" đến hết TTL. Nếu TTL là 24h, user phải đợi 24h mới có quyền mới. Giải pháp: TTL ngắn (5–15 phút), nhưng khi đó cần refresh flow.
3. Payload size per request
JWT header + payload + signature thường 500B–2KB. Với session ID chỉ 32–64 byte. Khi mỗi request gửi JWT trong header, bandwidth tăng theo. Ít quan trọng với HTTP/2, nhưng đáng chú ý với high-frequency API hoặc WebSocket handshake.
4. Secret management
Với HS256, một secret dùng chung để ký và verify. Secret bị lộ → attacker forge token tuỳ ý cho bất kỳ user ID nào. RS256 tách biệt private key (ký) và public key (verify), giảm rủi ro nhưng phức tạp hơn.
5. Không lưu sensitive data trong payload
Payload chỉ là Base64URL — bất kỳ proxy, CDN, log aggregator nào giữa client và server đều có thể decode được. Tránh đặt email, phone, địa chỉ, hay thông tin PII trong payload.
JWT Revocation — Blacklist Pattern
Workaround phổ biến cho vấn đề revocation: lưu JWT ID (jti) của token bị revoke vào Redis blacklist.
def revoke_jwt(jti: str, ttl_remaining: int) -> None:
# Lưu đến hết TTL token gốc — sau đó key tự expire, không tốn memory mãi
r.set(f"blacklist:jti:{jti}", "1", ex=ttl_remaining)
def verify_token_with_blacklist(token: str) -> dict:
claims = jwt.decode(token, SECRET, algorithms=["HS256"])
jti = claims.get("jti")
if jti and r.exists(f"blacklist:jti:{jti}"):
raise Exception("Token revoked")
return claims
Pattern này có vẻ giải được bài toán, nhưng có nghịch lý quan trọng: stateless token + Redis lookup mỗi request = mất ưu điểm stateless. Bạn đang trả thêm chi phí JWT (payload lớn, secret management) trong khi vẫn cần Redis I/O như session thông thường.
Blacklist pattern phù hợp nhất khi: chỉ cần revoke trường hợp đặc biệt (security incident, logout sớm) còn phần lớn traffic không cần lookup. Bài 81 sẽ đi chi tiết hơn về blacklist implementation.
Hybrid Pattern — JWT Access + Redis Refresh
Pattern này kết hợp điểm mạnh của cả hai: JWT access token có TTL ngắn (không cần blacklist), Redis refresh token có thể revoke bất kỳ lúc nào.
- Access token: JWT, TTL 5–15 phút. Gửi qua
Authorizationheader mỗi request. Server verify signature — không Redis lookup. - Refresh token: opaque string (random), lưu Redis, TTL 7–30 ngày. Dùng để lấy access token mới khi access expire.
import jwt, secrets, time
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
SECRET = "your-secret"
# ─── Login ────────────────────────────────────────────────
def login(user_id: int, role: str) -> dict:
access_token = jwt.encode(
{"sub": str(user_id), "role": role, "exp": int(time.time()) + 900},
SECRET, algorithm="HS256",
)
refresh_token = secrets.token_urlsafe(32)
r.set(f"refresh:{refresh_token}", str(user_id), ex=7 * 86400)
return {"access": access_token, "refresh": refresh_token}
# ─── Refresh ──────────────────────────────────────────────
def refresh(refresh_token: str, role: str) -> dict:
user_id = r.get(f"refresh:{refresh_token}")
if not user_id:
raise Exception("Invalid or expired refresh token")
new_access = jwt.encode(
{"sub": user_id, "role": role, "exp": int(time.time()) + 900},
SECRET, algorithm="HS256",
)
return {"access": new_access}
# ─── Logout ───────────────────────────────────────────────
def logout(refresh_token: str) -> None:
r.delete(f"refresh:{refresh_token}") # revoke ngay lập tức
Workflow:
- Login → server trả access (JWT, 15 phút) + refresh (opaque, 7 ngày).
- Client dùng access token cho mọi API request. Khi access expire (HTTP 401), client tự động gọi
POST /refreshvới refresh token → nhận access mới. - Logout → server
DEL refresh:{token}. Access cũ tự expire sau tối đa 15 phút.
Ưu điểm: không cần blacklist (JWT TTL ngắn đủ để "revoke" theo thực tế), refresh token revokable instant, Redis chỉ cần lưu refresh token (ít hơn nhiều so với session mỗi request).
Bài 82 đi chi tiết về refresh token rotation (issue refresh mới mỗi lần refresh, revoke refresh cũ) để chống token reuse.
Security — Cookie vs localStorage
Cách lưu token phía client ảnh hưởng trực tiếp đến bề mặt tấn công:
| Storage | XSS risk | CSRF risk | Ghi chú |
|---|---|---|---|
| Cookie HttpOnly | Thấp (JS không đọc được) | Có (tự động gửi) | Mitigate với SameSite=Strict |
| localStorage / sessionStorage | Cao (XSS dump được) | Thấp (phải gán header thủ công) | Không dùng cho web app có user data nhạy cảm |
| Cookie SameSite=Strict + Secure | Thấp | Thấp | Tốt nhất cho web SPA same-domain |
Cookie config đề xuất cho cả session ID lẫn refresh token (khi lưu cookie):
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600
HttpOnly: JavaScript không đọc được → XSS không lấy được cookie.Secure: chỉ gửi qua HTTPS.SameSite=Strict: không gửi trong cross-site request → chống CSRF.
Kết luận thực tế: với web app, lưu token trong HttpOnly cookie an toàn hơn localStorage đáng kể. localStorage tiện cho mobile/native app nơi không có browser context, nhưng trong browser nó là XSS vector rõ ràng.
Token Theft & Multi-Device Logout
Token theft scenarios
- Session ID bị đánh cắp: server detect IP hoặc User-Agent thay đổi bất thường → revoke session ngay bằng
DEL. Thiệt hại được giới hạn. - JWT bị đánh cắp: token valid đến
exp. Không có cơ chế server-side để detect sử dụng song song trừ khi có monitoring (lưu last-used + detect anomaly) hoặc có blacklist.
Multi-device logout
Stateful session: list session IDs per user trong Redis set, khi "logout all" thì DEL từng session:
def create_session_tracked(user_id: int, role: str) -> str:
session_id = secrets.token_urlsafe(32)
pipe = r.pipeline()
pipe.set(f"session:{session_id}", json.dumps({"user_id": user_id, "role": role}), ex=3600)
pipe.sadd(f"user:sessions:{user_id}", session_id)
pipe.expire(f"user:sessions:{user_id}", 30 * 86400)
pipe.execute()
return session_id
def logout_all_devices(user_id: int) -> None:
session_ids = r.smembers(f"user:sessions:{user_id}")
if session_ids:
pipe = r.pipeline()
for sid in session_ids:
pipe.delete(f"session:{sid}")
pipe.delete(f"user:sessions:{user_id}")
pipe.execute()
Hybrid JWT + Redis refresh: tương tự — track refresh token per user:
def logout_all_devices_jwt(user_id: int) -> None:
refresh_tokens = r.smembers(f"user:refresh_tokens:{user_id}")
if refresh_tokens:
pipe = r.pipeline()
for rt in refresh_tokens:
pipe.delete(f"refresh:{rt}")
pipe.delete(f"user:refresh_tokens:{user_id}")
pipe.execute()
# Access token cũ vẫn valid đến hết TTL 5-15 phút
Lưu ý: với hybrid, sau khi logout all, access token cũ vẫn valid thêm tối đa bằng TTL của nó (thường 5–15 phút). Chấp nhận được cho hầu hết use case.
Update Permission Flow
Khi role hoặc permission của user thay đổi (promote, demote, ban), ba cơ chế xử lý khác nhau:
Stateful session: update DB + update session blob trong Redis. Permission mới có hiệu lực từ request kế tiếp:
def update_user_role(user_id: int, new_role: str) -> None:
db.execute("UPDATE users SET role = %s WHERE id = %s", (new_role, user_id))
# Update tất cả session active của user này
session_ids = r.smembers(f"user:sessions:{user_id}")
for sid in session_ids:
raw = r.get(f"session:{sid}")
if raw:
data = json.loads(raw)
data["role"] = new_role
ttl = r.ttl(f"session:{sid}")
r.set(f"session:{sid}", json.dumps(data), ex=max(ttl, 1))
Stateless JWT: chỉ update DB. Claims trong token cũ vẫn là role cũ đến hết exp. Nếu TTL là 24h, user có thể dùng quyền cũ thêm 24h.
Workaround JWT: dùng TTL ngắn (5–15 phút). Sau đó permission mới được issue khi user refresh token. Không hoàn hảo nhưng lag tối đa 15 phút thường chấp nhận được.
Nếu cần permission update tức thì với JWT: kết hợp blacklist (revoke token hiện tại của user) hoặc chuyển sang session Redis.
Performance & Multi-Region
Latency numbers
- Session lookup Redis (local): 0.5–1ms (network round-trip).
- JWT verify HS256: 0.05–0.2ms (HMAC-SHA256 là CPU-bound, rất nhanh).
- JWT verify RS256: 0.5–2ms (RSA chậm hơn HS256 đáng kể).
Kết luận thực tế: JWT HS256 verify nhanh hơn session lookup nếu Redis có network latency. Nhưng khi Redis nằm cùng host hoặc cùng datacenter, latency của session lookup tương đương RS256. Ở scale lớn, 0.5ms mỗi request × hàng triệu request/giờ là chi phí đáng đo lường.
Multi-region
- JWT: verify anywhere với shared secret hoặc public key — không cross-region traffic. Request từ Singapore verify ngay ở Singapore, không cần gọi về US.
- Session: cần Redis accessible từ region đó. Các lựa chọn: Redis Cluster replication cross-region (độ trễ cao), hoặc route user về region "home" (phức tạp), hoặc Redis Global Database.
Multi-region là điểm JWT thực sự có lợi thế rõ ràng so với stateful session.
Decision Framework
Dựa trên các trade-off trên, framework chọn cơ chế:
| Kiến trúc / Yêu cầu | Khuyến nghị | Lý do chính |
|---|---|---|
| Monolith / ít service (<5) | Stateful session + Redis | Đơn giản, instant revoke, permission update tức thì |
| Web SPA same-domain | Stateful session + cookie HttpOnly | Bảo mật tốt hơn, không cần quản lý token ở JS |
| Microservices nhiều domain | Hybrid: JWT access (5–15m) + Redis refresh (7d) | Cross-service verify không cần central Redis mỗi request |
| 3rd-party / public API | JWT (long-lived với revoke strategy) | Client external, không share Redis |
| Mobile app native | Hybrid: JWT access + Redis refresh | Ít lookup hơn, battery-friendly, refresh khi cần |
| Multi-region global | JWT (HS256 / RS256) | Verify local không cần cross-region Redis |
| Cần instant revoke bắt buộc | Stateful session hoặc JWT + blacklist Redis | Stateless JWT thuần không revoke được |
Câu hỏi thực tế cần trả lời trước khi chọn:
- Có cần revoke session ngay lập tức không? (security, compliance)
- Các service có share Redis không, hay chạy độc lập?
- Client là browser (có cookie) hay native app / external?
- Có multi-region không?
- Permission thay đổi bao lâu một lần và lag bao nhiêu phút là chấp nhận được?
Anti-Patterns
- Long-lived JWT (24h+) không có blacklist: token theft window dài 24h. Nếu cần TTL dài, phải có refresh + revoke strategy.
- JWT lưu PII trong payload: email, phone, địa chỉ... đều có thể lộ qua proxy log, CDN access log, hay browser DevTools. Chỉ lưu ID và role trong JWT.
- JWT trong localStorage cho web: XSS script dump được
localStorage.getItem("token")và gửi ra ngoài. Dùng HttpOnly cookie thay thế. - Stateful session không có TTL: Redis key không expire → memory tăng vô hạn. Luôn set
exkhi tạo session. - Hybrid mà không track refresh token per user: user logout từ một thiết bị, thiết bị khác vẫn có refresh token active và tiếp tục lấy access mới. Cần
SADD user:refresh_tokens:{id}để list và revoke all. - Dùng RS256 mà private key không rotate: key compromise toàn bộ. Cần có key rotation strategy và
kid(key ID) trong JWT header để support multiple keys. - Ignore
expvalidation: một số implementation quên checkexp— token expire vẫn accept. Library nhưPyJWTcheck tự động, nhưng cần đảm bảo không passoptions={"verify_exp": False}ở production.
Tổng Kết & Quiz
Điểm chính của bài:
- Stateful session: server giữ state trong Redis, revoke instant, permission update instant, lookup ~1ms mỗi request.
- JWT: client giữ signed token, server verify CPU-only, scale tốt, nhưng không revoke được và permission lag đến hết TTL.
- Hybrid (JWT access ngắn + Redis refresh dài) kết hợp được: phần lớn request không lookup Redis, refresh token revokable, không cần blacklist.
- Lưu token trong HttpOnly cookie an toàn hơn localStorage cho web app.
- Multi-device logout và permission update ngay cần server-side state — JWT thuần không đủ.
Quiz
- Giải thích tại sao JWT + Redis blacklist mỗi request về cơ bản mất ưu điểm stateless của JWT.
- Trong hybrid pattern, sau khi user logout (DEL refresh token), access token cũ có còn valid không? Bao lâu? Tại sao đây thường là chấp nhận được?
- Tại sao không nên lưu email hay phone trong JWT payload dù payload được Base64-encode?
- Bạn đang xây monolith Django phục vụ web SPA trên cùng domain. Chọn stateful session hay JWT? Tại sao?
- Permission của user thay đổi ngay lập tức (compliance requirement). Bạn đang dùng JWT với TTL 15 phút. Cần thêm gì để đảm bảo permission mới có hiệu lực ngay?
Đáp án gợi ý
- Với blacklist, mỗi request vẫn cần Redis
EXISTSlookup để kiểm tra token bị revoke chưa. Đây là I/O tương đương session lookup, nhưng cộng thêm chi phí JWT (payload lớn, signature verify). Chỉ phù hợp khi revoke là trường hợp hiếm và muốn tận dụng cross-service verify mà không blacklist thường xuyên. - Có — access token vẫn valid đến
expcủa nó, tối đa bằng TTL (5–15 phút). Chấp nhận được vì window nhỏ, và attacker cần cả access token lẫn refresh token để exploit lâu dài — sau khi refresh bị DEL, không lấy được access mới. - Base64URL chỉ encode, không mã hoá. Bất kỳ ai có token (proxy, CDN, log) đều decode được payload bằng một dòng code. PII trong JWT có thể lộ qua log aggregation, error tracking, hay browser DevTools.
- Stateful session + cookie HttpOnly. Cùng domain nên không cần cross-service verify. Session đơn giản hơn, instant revoke, permission update tức thì, và cookie HttpOnly an toàn hơn localStorage.
- Cần revoke token hiện tại của user ngay (đưa
jtivào Redis blacklist) hoặc force logout user (nếu dùng refresh, DEL refresh token để user phải login lại). Chỉ dùng short TTL không đủ nếu yêu cầu là "ngay lập tức".
Bài tiếp theo
Bài 78 đi vào implement session store production-grade: namespace key, TTL strategy, sliding expiration, session fixation protection, và cách test.
