Design review cho API mới, câu hỏi quen thuộc: JWT hay session? Một bên muốn stateless để scale, bên kia lo không revoke được token khi cần. Tranh luận kéo dài cho đến khi ai đó hỏi đúng câu: “API phục vụ ai, cần revoke trong bao lâu, bao nhiêu service cần verify?” — câu trả lời cho ba câu đó quyết định cơ chế phù hợp, không phải sở thích cá nhân.

Bài này so sánh session-based và JWT từ góc kỹ thuật: cách hoạt động, trade-off thực tế, và khi nào nên chọn cái nào.


Session-based authentication — stateful và đơn giản

Session-based auth là mô hình cổ điển nhất và vẫn hoạt động tốt cho rất nhiều hệ thống hiện đại. Flow cơ bản diễn ra như sau: user gửi credentials (username + password), server xác minh, tạo một session ID ngẫu nhiên, lưu session data vào server-side store (memory, Redis, database), rồi trả session ID về client qua cookie. Mọi request tiếp theo, browser tự gửi cookie chứa session ID — server tra cứu session store, biết user là ai, trả response tương ứng.

Điểm cốt lõi cần hiểu: toàn bộ trạng thái xác thực nằm trên server. Client chỉ giữ một chuỗi ký tự ngẫu nhiên — session ID — không chứa bất kỳ thông tin nào về user. Session ID giống vé gửi xe: tờ vé không nói gì về chiếc xe, nhưng đưa cho bãi giữ xe thì họ biết trả đúng xe.

# Server tạo session sau khi verify credentials
session_id = generate_random_bytes(32).hex()
session_store.set(session_id, {
    "user_id": user.id,
    "role": user.role,
    "created_at": now(),
    "expires_at": now() + timedelta(hours=24)
})

# Set cookie trong response
response.set_cookie(
    "session_id", session_id,
    httponly=True, secure=True,
    samesite="Lax", max_age=86400
)

Ưu điểm lớn nhất của session: revocation tức thì. Khi cần vô hiệu hoá phiên đăng nhập — user đổi mật khẩu, admin khoá tài khoản, phát hiện bị hack — chỉ cần xoá session khỏi store. Request tiếp theo với session ID đó sẽ bị reject ngay lập tức. Không có độ trễ, không có token còn valid lơ lửng ngoài kia.

Nhược điểm rõ ràng nhất: server phải lưu trữ và tra cứu session mỗi request. Với một server đơn lẻ, session store trong memory là đủ. Nhưng khi scale ra nhiều server (load balancer phân request ngẫu nhiên), mỗi server cần truy cập cùng session store — thường là Redis cluster hoặc database. Đây không phải vấn đề kỹ thuật khó giải quyết (Redis rất nhanh, sub-millisecond cho GET), nhưng nó thêm một thành phần hạ tầng cần vận hành, monitor, và đảm bảo availability.


JWT — token tự chứa thông tin

JWT (JSON Web Token) đi theo hướng ngược lại: thay vì lưu trạng thái trên server, nhồi toàn bộ thông tin cần thiết vào token và gửi cho client. Server không cần nhớ gì — chỉ cần verify chữ ký của token là biết token hợp lệ hay không.

Cấu trúc ba phần

Một JWT gồm ba phần phân cách bằng dấu chấm: header.payload.signature, mỗi phần được Base64URL-encode.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzQyIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzE0MDAwMDAwfQ.
kP3L8x7...signature

Header chứa thuật toán ký (alg) và loại token (typ). Payload chứa claims — các cặp key-value mang thông tin: sub (subject — thường là user ID), exp (expiration time), iss (issuer), aud (audience), cùng các custom claims như role, permissions. Signature là kết quả ký header + payload bằng secret key hoặc private key — đảm bảo không ai sửa được payload mà server không phát hiện.

Lưu ý quan trọng: payload chỉ được encode (Base64URL), không được mã hoá. Bất kỳ ai có token đều decode được payload và đọc nội dung. Đừng bao giờ đặt dữ liệu nhạy cảm — mật khẩu, số thẻ tín dụng, PII không cần thiết — vào JWT payload. Nếu cần mã hoá payload, sử dụng JWE (JSON Web Encryption) — nhưng hầu hết use case không cần đến mức đó.

Thuật toán ký: symmetric vs asymmetric

HS256 (HMAC-SHA256) dùng một secret key duy nhất cho cả ký và verify. Đơn giản, nhanh, phù hợp khi cùng một service vừa phát hành vừa verify token. Nhưng nếu service A phát hành token và service B cần verify, B phải biết secret key — và khi B biết secret key, B cũng có thể phát hành token giả. Trong kiến trúc microservices, chia sẻ secret key giữa nhiều service là rủi ro bảo mật nghiêm trọng.

RS256 (RSA-SHA256) và ES256 (ECDSA-SHA256) dùng cặp khoá bất đối xứng: private key để ký (chỉ auth service giữ), public key để verify (bất kỳ service nào cũng có thể giữ). Service downstream verify token mà không thể phát hành token mới — đúng mô hình trust cần thiết.

// Auth service — ký token bằng private key
const token = jwt.sign(
  { sub: "user_42", role: "admin" },
  privateKey,
  { algorithm: "RS256", expiresIn: "15m" }
);

// Bất kỳ service nào — verify bằng public key
const payload = jwt.verify(token, publicKey, {
  algorithms: ["RS256"],
  issuer: "auth.example.com",
  audience: "api.example.com"
});

Quy tắc đơn giản: một service duy nhất phát hành và verify → HS256 đủ dùng. Nhiều service cần verify → dùng RS256 hoặc ES256. ES256 cho khoá ngắn hơn và ký nhanh hơn RS256 ở cùng mức bảo mật — đang dần trở thành lựa chọn ưu tiên.

Một chi tiết nữa cần biết: khi dùng asymmetric key, public key thường được phân phối qua JWKS endpoint (JSON Web Key Set) — một URL mà bất kỳ service nào cũng có thể gọi để lấy public key hiện tại. Auth server expose /.well-known/jwks.json, các service downstream cache public key từ endpoint này và refresh định kỳ. Khi cần rotate key, thêm key mới vào JWKS, phát hành token mới với key mới, giữ key cũ trong JWKS cho đến khi token cũ hết hạn, rồi xoá key cũ. Quy trình này cho phép key rotation không downtime — điều rất khó làm với HS256 shared secret.


Access token và refresh token

Một JWT access token có thời hạn ngắn — thường 15 phút. Nếu chỉ có access token, user phải đăng nhập lại mỗi 15 phút — trải nghiệm tệ. Refresh token giải quyết vấn đề này: khi access token hết hạn, client dùng refresh token để xin access token mới mà không cần nhập lại credentials.

Flow hoạt động: user đăng nhập → server trả cả access token (ngắn hạn, 15 phút) và refresh token (dài hạn, 7-30 ngày). Client dùng access token cho mọi API call. Khi access token hết hạn (server trả 401), client gửi refresh token tới endpoint /auth/refresh → server verify refresh token, phát hành access token mới.

Client                    Auth Server              API Server
  |--- POST /login -------->|                          |
  |<-- access + refresh ----|                          |
  |                         |                          |
  |--- GET /api (access) ---|------------------------->|
  |<-- 200 data ------------|--------------------------|
  |                         |                          |
  |   [access token hết hạn]                           |
  |--- GET /api (access) ---|------------------------->|
  |<-- 401 ------------------|--------------------------|
  |                         |                          |
  |--- POST /refresh ------>|                          |
  |<-- new access token ----|                          |
  |--- GET /api (new access)|------------------------->|
  |<-- 200 data ------------|--------------------------|

Refresh token phải được lưu server-side (database hoặc Redis) — khác với access token. Lý do: refresh token cần có khả năng revoke. Khi user logout hoặc bị khoá tài khoản, server xoá refresh token khỏi store — client không thể lấy access token mới nữa. Access token cũ vẫn valid cho đến khi hết hạn (tối đa 15 phút), nhưng đó là trade-off chấp nhận được cho hầu hết hệ thống.

Refresh token rotation và phát hiện tái sử dụng

Refresh token rotation là pattern bảo mật quan trọng: mỗi lần client dùng refresh token để lấy access token mới, server phát hành refresh token mới và vô hiệu hoá refresh token cũ. Nếu kẻ tấn công đánh cắp được refresh token và dùng trước user thật, khi user thật dùng refresh token cũ (đã bị vô hiệu hoá), server phát hiện tái sử dụng (reuse detection) — dấu hiệu token bị leak — và vô hiệu hoá toàn bộ chuỗi refresh token của user đó, buộc đăng nhập lại.

def refresh_token_handler(old_refresh_token):
    token_record = db.find_refresh_token(old_refresh_token)

    if token_record is None:
        return 401  # Token không tồn tại

    if token_record.is_used:
        # Reuse detection — token đã dùng rồi mà có người dùng lại
        # Có thể bị đánh cắp → vô hiệu hoá toàn bộ family
        db.revoke_all_tokens_in_family(token_record.family_id)
        return 401

    # Đánh dấu token cũ đã dùng
    db.mark_as_used(old_refresh_token)

    # Phát hành cặp token mới, cùng family
    new_access = generate_access_token(token_record.user_id)
    new_refresh = generate_refresh_token(
        user_id=token_record.user_id,
        family_id=token_record.family_id
    )
    return { "access_token": new_access, "refresh_token": new_refresh }

Khái niệm “token family” ở đây rất quan trọng: mỗi lần đăng nhập tạo một family mới. Mọi refresh token sinh ra từ family đó đều liên kết — khi phát hiện reuse, revoke cả family thay vì chỉ một token đơn lẻ.


Lưu trữ token phía client — không có giải pháp hoàn hảo

Câu hỏi “lưu token ở đâu trên client” là một trong những tranh luận phổ biến nhất, và thực tế không có giải pháp hoàn hảo — chỉ có trade-off khác nhau giữa XSS và CSRF.

httpOnly cookie ngăn JavaScript truy cập token — XSS attack không đọc được cookie. Nhưng cookie tự động gửi theo mọi request tới domain — dễ bị CSRF (Cross-Site Request Forgery) nếu không có biện pháp bảo vệ. Cần kết hợp SameSite=Lax hoặc Strict, CSRF token, hoặc cả hai.

localStorage cho phép JavaScript đọc/ghi tự do — nếu trang web có lỗ hổng XSS, attacker inject script đọc token từ localStorage và gửi về server riêng. Một khi token bị đánh cắp qua XSS, attacker có toàn quyền truy cập API cho đến khi token hết hạn.

Lựa chọn được coi là an toàn hơn cho web application: lưu access token trong httpOnly, Secure, SameSite cookie. Kết hợp với CSRF protection (SameSite attribute thường đủ cho trình duyệt hiện đại, thêm CSRF token nếu cần hỗ trợ trình duyệt cũ hoặc cross-origin request).

Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900

HttpOnly ngăn JavaScript đọc cookie. Secure chỉ gửi cookie qua HTTPS. SameSite=Lax ngăn cookie gửi theo cross-site request (trừ navigation top-level GET) — giảm đáng kể rủi ro CSRF. Ba attribute này là bộ ba bắt buộc cho mọi cookie xác thực.

Với mobile app hoặc SPA gọi API cross-origin, cookie phức tạp hơn (cần CORS config đúng). Một pattern phổ biến là lưu access token trong memory (biến JavaScript), refresh token trong httpOnly cookie. Access token mất khi refresh trang — client gọi /auth/refresh để lấy lại. Trade-off là thêm một request khi load trang, nhưng access token không bao giờ nằm trong storage bị XSS đọc được.


Bài toán revocation — điểm yếu cốt lõi của JWT

Đây là trade-off lớn nhất khi chọn JWT. Với session, revoke = xoá session khỏi store, hiệu lực ngay lập tức. Với JWT, token đã phát hành thì server không có cách nào “thu hồi” — token tự chứa mọi thông tin cần thiết để verify, không cần hỏi server.

Nếu access token bị đánh cắp và thời hạn còn 14 phút, attacker có 14 phút toàn quyền truy cập. Đây là lý do access token phải ngắn hạn — 15 phút là con số phổ biến. Ngắn hơn (5 phút) giảm cửa sổ tấn công nhưng tăng tần suất refresh. Dài hơn (1 giờ) thuận tiện hơn nhưng cửa sổ tấn công rộng hơn.

Khi cần revocation tức thì (user bị khoá, phát hiện breach), có hai pattern phổ biến.

Token blocklist: server duy trì danh sách JWT đã bị vô hiệu hoá (thường lưu trong Redis với TTL bằng thời hạn còn lại của token). Mỗi request, sau khi verify chữ ký JWT, server kiểm tra thêm blocklist. Nếu token nằm trong blocklist → reject.

def verify_request(token):
    payload = jwt.decode(token, public_key, algorithms=["RS256"])

    # Kiểm tra blocklist
    if redis.exists(f"blocklist:{payload['jti']}"):
        raise Unauthorized("Token has been revoked")

    return payload

Nhược điểm hiển nhiên: bạn vừa thêm một bước tra cứu server-side vào mỗi request — mất đi lợi ích “stateless” của JWT. Nhưng blocklist thường rất nhỏ (chỉ chứa token bị revoke chủ động, không phải mọi token) và TTL tự dọn, nên overhead thấp hơn nhiều so với full session store.

Token versioning: mỗi user có một token_version trong database. JWT chứa version claim. Khi cần revoke tất cả token của user, tăng token_version trong database. Mọi token cũ (version < version hiện tại) bị reject. Vẫn cần một lần tra cứu database, nhưng có thể cache aggressive vì version hiếm khi thay đổi.

Cả hai pattern đều thêm state vào hệ thống “stateless” — điều đó hoàn toàn ổn. Stateless thuần tuý (không bao giờ tra cứu server) chỉ khả thi khi bạn chấp nhận không revoke được token cho đến khi hết hạn. Với hầu hết hệ thống production, đây là rủi ro không chấp nhận được.


JWT pitfalls — những lỗi phổ biến

None algorithm attack

JWT spec cho phép alg: "none" — không ký. Nếu server không kiểm tra thuật toán, attacker gửi token với alg: "none", bỏ signature, server chấp nhận token không ký. Đây là lỗi kinh điển nhất của JWT.

Phòng chống: luôn chỉ định danh sách thuật toán được phép khi verify, không để library tự chọn từ header.

// SAI — library đọc alg từ header, attacker control được
jwt.verify(token, key);

// ĐÚNG — chỉ chấp nhận RS256
jwt.verify(token, key, { algorithms: ["RS256"] });

Không validate đủ claims

Verify signature chỉ đảm bảo token không bị sửa — chưa đảm bảo token dành cho hệ thống của bạn. Luôn validate: exp (token hết hạn chưa), iss (ai phát hành — có phải auth server của bạn không), aud (token dành cho service nào — service A không nên chấp nhận token dành cho service B).

jwt.verify(token, publicKey, {
  algorithms: ["RS256"],
  issuer: "https://auth.example.com",
  audience: "https://api.example.com",
  clockTolerance: 30  // cho phép lệch clock 30 giây giữa server
});

Thiếu validate aud là lỗi hay gặp trong microservices: service A phát hành token cho user truy cập service A, nhưng user dùng token đó gọi service B — nếu B không kiểm tra aud, B chấp nhận token không dành cho mình.

Đặt dữ liệu nhạy cảm trong payload

JWT payload chỉ được Base64URL-encode, không mã hoá. Bất kỳ ai intercept hoặc decode token đều đọc được toàn bộ nội dung. Đặt email, số điện thoại, hoặc permission chi tiết vào payload nghĩa là mỗi lần token bị log (access log, error log, debug log) thì dữ liệu đó cũng bị log theo. Chỉ đặt vào payload những gì cần thiết cho authorization: user ID, role, và các claim tối thiểu. Nếu service cần thêm thông tin, dùng user ID từ token để tra cứu database — đừng biến JWT thành mini user profile.

Key confusion attack

Xảy ra khi server dùng RS256 (asymmetric) nhưng attacker gửi token với alg: "HS256", dùng public key (đã công khai) làm HMAC secret để ký. Nếu library verify bằng cách: lấy alg từ header, nếu HS256 thì dùng “key” (là public key) làm HMAC secret — token sẽ pass verification. Giải pháp tương tự: chỉ định danh sách thuật toán cố định, không đọc từ header.


Session hay JWT — khi nào chọn cái nào

Không có câu trả lời “X tốt hơn Y” — chỉ có câu trả lời phù hợp với constraints cụ thể.

Session phù hợp khi hệ thống là monolith hoặc ít service, cần revocation tức thì (fintech, healthcare, admin panel), team đã có Redis/database infrastructure, và ưu tiên đơn giản. Session không cần xử lý refresh token rotation, không lo JWT pitfalls, không cần blocklist. Đổi lại, mỗi request tra cứu session store — nhưng Redis GET sub-millisecond, hiếm khi là bottleneck thực tế.

JWT phù hợp khi kiến trúc microservices với nhiều service cần verify authentication độc lập. Thay vì mọi service gọi về auth service hoặc Redis để tra cứu session, mỗi service tự verify JWT bằng public key — giảm coupling và single point of failure. JWT cũng phù hợp cho API phục vụ third-party client (OAuth 2.0 resource server), mobile app, hoặc khi cần truyền claims giữa service mà không cần tra cứu thêm.

Hybrid approach là pattern thực tế nhất cho hệ thống phức tạp: dùng session (hoặc opaque token + server-side store) cho user-facing authentication — user đăng nhập, được cấp session. Khi request từ API gateway đi vào hệ thống microservices nội bộ, gateway đổi session thành JWT ngắn hạn (1-5 phút) để các service downstream verify mà không cần gọi về session store. JWT nội bộ này không bao giờ lộ ra client.

Browser → [session cookie] → API Gateway → [JWT nội bộ] → Service A → Service B
                                  ↕
                            Session Store (Redis)

Mô hình này lấy ưu điểm của cả hai: revocation tức thì tại gateway (xoá session), stateless verification trong nội bộ (JWT). Gateway là điểm duy nhất tra cứu session — các service phía sau không cần biết Redis tồn tại.


Thời hạn token — cân bằng bảo mật và trải nghiệm

Access token ngắn hạn: 15 phút là con số phổ biến và hợp lý cho hầu hết API. Ngắn hơn (5 phút) thì client refresh liên tục, tạo thêm load cho auth server. Dài hơn (1-2 giờ) thì cửa sổ tấn công rộng khi token bị đánh cắp — cần đánh giá rủi ro cụ thể.

Refresh token dài hạn: 7 ngày cho ứng dụng web thông thường, 30-90 ngày cho mobile app (user không muốn đăng nhập lại mỗi tuần trên điện thoại). Kết hợp refresh token rotation để giảm rủi ro — mỗi lần dùng refresh token, token cũ bị vô hiệu hoá.

Session timeout cũng cần hai loại: absolute timeout (session tồn tại tối đa 24 giờ bất kể hoạt động) và idle timeout (session hết hạn nếu không hoạt động trong 30 phút). Absolute timeout đảm bảo session không tồn tại vĩnh viễn dù user không logout. Idle timeout đảm bảo session trên máy công cộng tự hết hạn khi user bỏ đi.

Đối với hệ thống nhạy cảm (ngân hàng, y tế), áp dụng thêm step-up authentication: session vẫn valid cho thao tác thường, nhưng thao tác nhạy cảm (chuyển tiền, xem hồ sơ bệnh) yêu cầu xác thực lại (nhập mật khẩu hoặc OTP) ngay cả khi session còn hiệu lực.

Một sai lầm hay gặp là đặt refresh token quá dài hạn (90 ngày, 1 năm) mà không có cơ chế phát hiện bất thường. Refresh token tồn tại lâu nghĩa là nếu bị đánh cắp, attacker có cửa sổ tấn công rất rộng. Kết hợp refresh token rotation (đã nói ở trên) với phát hiện bất thường — login từ IP/device mới, refresh từ quốc gia khác — để thu hẹp rủi ro. Một số hệ thống còn yêu cầu re-authentication khi phát hiện device fingerprint thay đổi giữa hai lần refresh.


Checklist bảo mật cho cả hai cơ chế

Dù chọn session hay JWT, một số nguyên tắc bảo mật áp dụng chung cho cả hai.

Truyền tải phải qua HTTPS — không bao giờ gửi token hay session ID qua HTTP thuần. Cookie phải có attribute Secure để trình duyệt chỉ gửi qua HTTPS. Nghe hiển nhiên nhưng dev environment thường dùng HTTP, và đôi khi config production quên bật Secure.

Session ID và refresh token phải đủ entropy — tối thiểu 128-bit random, dùng CSPRNG (cryptographically secure pseudo-random number generator). Đừng tự implement — dùng function có sẵn của framework (crypto.randomBytes trong Node.js, secrets.token_hex trong Python).

Với JWT, secret key cho HS256 phải đủ dài (tối thiểu 256-bit, tức 32 bytes random). Key ngắn hoặc dễ đoán (dùng password thông thường làm secret) thì attacker brute-force được. Với RS256/ES256, private key phải được bảo vệ nghiêm ngặt — lưu trong vault (HashiCorp Vault, AWS KMS), không commit vào repo, không truyền qua environment variable dạng plaintext trên shared system.

Rate limit endpoint đăng nhập và refresh token — ngăn brute-force credentials và token stuffing. Endpoint /auth/login nên có rate limit chặt (5-10 request/phút/IP). Endpoint /auth/refresh cũng cần rate limit vì nó phát hành token mới — attacker có refresh token bị leak có thể spam endpoint này để tạo nhiều access token trước khi bị phát hiện.

Log mọi sự kiện xác thực: đăng nhập thành công, đăng nhập thất bại, refresh token, logout, revocation. Đây là dữ liệu cần thiết cho audit trail và phát hiện bất thường — brute-force, credential stuffing, token reuse đều để lại dấu vết trong log nếu bạn ghi đủ thông tin.

Nhớ không log token value — log token ID (jti) hoặc session ID prefix là đủ để trace mà không leak credentials.


Tóm tắt

Session-based auth đơn giản, revocation tức thì, phù hợp monolith và hệ thống cần kiểm soát chặt phiên đăng nhập. Trade-off là mỗi request tra cứu session store — nhưng Redis sub-millisecond nên hiếm khi là bottleneck thực tế. JWT stateless, self-contained, phù hợp microservices nơi nhiều service cần verify authentication độc lập mà không coupling vào session store chung. Trade-off là revocation không tức thì — cần blocklist hoặc token versioning, và phải xử lý đúng nhiều pitfall (none algorithm, key confusion, validate claims đầy đủ).

Access token ngắn hạn (15 phút) kết hợp refresh token rotation là pattern chuẩn cho JWT. Lưu token trong httpOnly Secure SameSite cookie an toàn hơn localStorage trên web. Hybrid approach — session cho user-facing, JWT cho inter-service — lấy ưu điểm cả hai và là lựa chọn thực tế nhất cho hệ thống phức tạp.

Quyết định cuối cùng không phải “JWT hay session” mà là ba câu hỏi: cần revoke trong bao lâu, bao nhiêu service cần verify, và team có sẵn hạ tầng gì. Trả lời ba câu đó, cơ chế phù hợp sẽ rõ ràng — và đừng ngại kết hợp cả hai khi bài toán đòi hỏi.