Danh sách bài viết

Bài 77: Stateful Session vs Stateless JWT — So Sánh

Bài 76 đã giải thích tại sao session store cần Redis. Bài này so sánh trực tiếp hai cơ chế xác thực: stateful session (server lưu state, client cầm session ID) và stateless JWT (client cầm signed token). Nội dung gồm bảng so sánh tiêu chí kỹ thuật, phân tích JWT pros/cons thực tế, pattern hybrid JWT access + Redis refresh token, security considerations (cookie vs localStorage, token theft), và decision framework chọn cơ chế phù hợp với từng loại kiến trúc.

01/06/2026
0 lượt xem
1

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

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

3

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

4

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.

5

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.

6

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.

7

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.

8

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.

9

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

  1. Login → server trả access (JWT, 15 phút) + refresh (opaque, 7 ngày).
  2. Client dùng access token cho mọi API request. Khi access expire (HTTP 401), client tự động gọi POST /refresh với refresh token → nhận access mới.
  3. 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.

10

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.

11

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.

12

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.

13

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.

14

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:

  1. Có cần revoke session ngay lập tức không? (security, compliance)
  2. Các service có share Redis không, hay chạy độc lập?
  3. Client là browser (có cookie) hay native app / external?
  4. Có multi-region không?
  5. Permission thay đổi bao lâu một lần và lag bao nhiêu phút là chấp nhận được?
15

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 ex khi 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 exp validation: một số implementation quên check exp — token expire vẫn accept. Library như PyJWT check tự động, nhưng cần đảm bảo không pass options={"verify_exp": False} ở production.
16

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

  1. 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.
  2. 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?
  3. Tại sao không nên lưu email hay phone trong JWT payload dù payload được Base64-encode?
  4. 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?
  5. 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 ý

  1. Với blacklist, mỗi request vẫn cần Redis EXISTS lookup để 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.
  2. Có — access token vẫn valid đến exp củ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.
  3. 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.
  4. 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.
  5. Cần revoke token hiện tại của user ngay (đưa jti và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.

Tham khảo