JWT vs Session: Stateless và Stateful authentication
On this page
“Nên dùng JWT hay session?” là câu hỏi không có đáp án chung. Bài này giải thích bản chất của hai cách tiếp cận, cái giá thật sự của “stateless”, và cách chọn đúng cho hệ thống của bạn.
TL;DR#
| Session (stateful) | JWT (stateless) | |
|---|---|---|
| Trạng thái đăng nhập lưu ở đâu | Server (memory, Redis, DB) | Trong chính token do client giữ |
| Client giữ gì | Session ID (chuỗi ngẫu nhiên, vô nghĩa) | Token chứa claims + chữ ký |
| Server xác thực bằng cách | Tra cứu session store | Verify chữ ký + kiểm tra exp |
| Thu hồi (logout, khóa tài khoản) | Dễ: xóa session | Khó: token vẫn hợp lệ tới khi hết hạn |
| Scale ngang | Cần session store dùng chung hoặc sticky session | Dễ: service nào có key cũng verify được |
| Kích thước | Nhỏ (~32 byte) | Lớn hơn (vài trăm byte đến vài KB), gửi kèm mọi request |
| Phù hợp | Web app truyền thống, monolith, cần kiểm soát chặt | API, microservices, mobile, SSO/OAuth2, service-to-service |
Ba điều cần nhớ:
- Stateless không miễn phí: đổi lại khả năng scale là việc mất khả năng thu hồi tức thì. Hầu hết hệ thống JWT thực tế đều phải thêm một chút state trở lại (refresh token, blacklist).
- Cách lưu token quan trọng không kém chọn loại token:
localStoragedễ bị XSS, cookie dễ bị CSRF — mỗi lựa chọn có biện pháp phòng thủ riêng. - Không có lựa chọn “luôn đúng”: với một web app monolith, session + Redis thường đơn giản và an toàn hơn JWT.
1. Stateful vs Stateless là gì?#
HTTP bản chất là stateless: mỗi request độc lập, server không “nhớ” request trước. Authentication cần một cách để server biết “request này đến từ user nào”.
- Stateful: server lưu trạng thái của mỗi client đã đăng nhập. Request chỉ cần mang một “chìa khóa” để server tra ra trạng thái đó.
- Stateless: server không lưu gì về client giữa các request. Mọi thông tin cần thiết để xác thực nằm trong chính request và server tự kiểm chứng được tính hợp lệ.
flowchart LR
subgraph Stateful
C1["Client: sessionId=abc123"] --> S1[Server]
S1 --> ST[("Session Store: abc123 → user 42, role ADMIN")]
end
subgraph Stateless
C2["Client: JWT chứa sub=42, role=ADMIN, chữ ký"] --> S2["Server: verify chữ ký bằng key"]
end
2. Session-based authentication#
2.1. Luồng hoạt động#
sequenceDiagram
participant B as Browser
participant S as Server
participant SS as Session Store
B->>S: POST /login (username, password)
S->>S: Kiểm tra credentials
S->>SS: Tạo session abc123 → userId 42
S-->>B: Set-Cookie: SESSION=abc123, HttpOnly, Secure, SameSite
Note over B: Browser tự động gửi cookie ở các request sau
B->>S: GET /orders + Cookie SESSION=abc123
S->>SS: Tra cứu abc123
SS-->>S: userId 42
S-->>B: 200 OK
B->>S: POST /logout
S->>SS: Xóa abc123
Note over SS: Session vô hiệu ngay lập tức
Session ID chỉ là một chuỗi ngẫu nhiên đủ dài (không đoán được), bản thân nó không chứa thông tin gì.
2.2. Ưu điểm#
- Thu hồi tức thì: logout, đổi mật khẩu, khóa tài khoản, “đăng xuất khỏi mọi thiết bị” → chỉ cần xóa session.
- Dữ liệu nhạy cảm không rời server.
- Đơn giản, trưởng thành: framework hỗ trợ sẵn (Spring Security mặc định dùng
HttpSession). - Cookie nhỏ, overhead mỗi request thấp.
- Thay đổi quyền của user có hiệu lực ngay.
2.3. Nhược điểm#
- Mỗi request cần tra cứu session store — thêm một round-trip (thường rất nhanh với Redis).
- Scale ngang phức tạp hơn: session lưu trong memory của instance A thì instance B không biết.
- Không tự nhiên cho cross-domain (cookie gắn với domain) và cho client không phải browser.
- Cần phòng thủ CSRF vì browser tự động gửi cookie.
2.4. Scale session ra nhiều instance#
flowchart TB
LB[Load Balancer]
LB --> A[Instance A]
LB --> B[Instance B]
LB --> C[Instance C]
A --> R[("Redis - Session Store dùng chung")]
B --> R
C --> R
| Cách | Mô tả | Đánh giá |
|---|---|---|
| Sticky session | Load balancer luôn gửi user tới cùng instance | Đơn giản nhưng instance chết là mất session, phân tải không đều |
| Session replication | Các instance đồng bộ session cho nhau | Tốn băng thông, khó scale lớn |
| Centralized store | Lưu session ở Redis/DB dùng chung | Phổ biến nhất. Với Spring: Spring Session + Redis |
Với Spring Session, chỉ cần thêm dependency và cấu hình Redis; code dùng HttpSession không phải thay đổi.
3. JWT-based authentication#
3.1. Cấu trúc JWT#
JWT (JSON Web Token, RFC 7519) gồm ba phần Base64URL nối bằng dấu chấm:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI0MiIsInJvbGUiOiJBRE1JTiIsImV4cCI6MTc1OTAwMDAwMH0 . SflKxwRJSMeKKF2QT4fwpM...
HEADER PAYLOAD SIGNATURE
// Header
{ "alg": "RS256", "typ": "JWT", "kid": "key-2026-09" }
// Payload (claims)
{
"iss": "https://auth.shop.com", // issuer
"sub": "42", // subject - user ID
"aud": "order-service", // audience
"iat": 1759000000, // issued at
"exp": 1759000900, // expiration - 15 phút sau
"jti": "a1b2c3", // token ID
"roles": ["ADMIN"]
}
Signature = ký (header + . + payload) bằng secret hoặc private key.
Điểm cực kỳ quan trọng: payload chỉ được encode, không được mã hóa. Bất kỳ ai có token đều đọc được nội dung (thử dán vào jwt.io). Chữ ký chỉ đảm bảo không bị sửa đổi, không đảm bảo bí mật. Không bao giờ đặt mật khẩu, số thẻ, dữ liệu cá nhân nhạy cảm vào payload. (Cần bí mật thì dùng JWE — JWT mã hóa — nhưng hiếm khi cần.)
3.2. Luồng hoạt động#
sequenceDiagram
participant C as Client
participant AS as Auth Service
participant API as Order Service
C->>AS: POST /login (credentials)
AS->>AS: Kiểm tra credentials, ký JWT bằng private key
AS-->>C: access_token (JWT, 15 phút) + refresh_token
C->>API: GET /orders + Authorization: Bearer JWT
API->>API: Verify chữ ký bằng public key, kiểm tra exp, iss, aud
Note over API: KHÔNG cần gọi Auth Service hay DB
API-->>C: 200 OK
3.3. Thuật toán ký#
| HS256 (HMAC) | RS256 / ES256 (bất đối xứng) | |
|---|---|---|
| Key | Một secret dùng chung cho ký và verify | Private key để ký, public key để verify |
| Ai verify được | Chỉ ai có secret — mà có secret thì cũng ký được | Bất kỳ ai có public key, nhưng không ký được |
| Phù hợp | Một service vừa phát hành vừa kiểm tra | Microservices, nhiều bên cần verify |
| Phân phối key | Phải chia sẻ secret an toàn | Công khai qua JWKS endpoint (/.well-known/jwks.json), hỗ trợ xoay key qua kid |
Với microservices, nên dùng RS256 hoặc ES256: service nào cũng verify được, nhưng chỉ Auth Server phát hành được token.
3.4. Ưu điểm#
- Không cần tra cứu state ở mỗi request → giảm tải DB/Redis, latency thấp.
- Scale ngang dễ dàng: mọi instance, mọi service chỉ cần public key.
- Hợp với microservices, mobile, cross-domain, SSO, và là định dạng chuẩn trong OAuth2 / OpenID Connect.
- Mang được thông tin (roles, tenant) giúp service khỏi phải query thêm.
3.5. Nhược điểm — phần hay bị xem nhẹ#
- Không thu hồi được ngay: user logout, bị khóa, bị đổi quyền → token cũ vẫn hợp lệ tới
exp. - Dữ liệu trong token có thể lỗi thời: quyền đã bị thu hồi nhưng token vẫn ghi
ADMIN. - Kích thước lớn, gửi kèm mọi request; nhồi nhiều claims có thể vượt giới hạn header.
- Dễ cấu hình sai dẫn tới lỗ hổng bảo mật nghiêm trọng (xem phần 6).
- Quản lý key (xoay key, lộ key) phức tạp hơn.
4. Giải quyết bài toán thu hồi JWT#
Đây là câu hỏi follow-up gần như chắc chắn trong phỏng vấn.
4.1. Access token ngắn hạn + Refresh token#
sequenceDiagram
participant C as Client
participant AS as Auth Service
participant DB as Token Store
participant API as Resource API
C->>API: Request + access_token
API-->>C: 401 - token hết hạn
C->>AS: POST /refresh + refresh_token
AS->>DB: Refresh token còn hợp lệ, chưa bị thu hồi?
DB-->>AS: Hợp lệ
AS->>DB: Vô hiệu refresh token cũ, lưu refresh token mới
AS-->>C: access_token mới + refresh_token mới
C->>API: Retry request + access_token mới
API-->>C: 200 OK
- Access token: sống ngắn (5–15 phút), stateless, dùng cho mọi request.
- Refresh token: sống dài (vài ngày đến vài tuần), được lưu và kiểm tra phía server — đây chính là phần stateful. Logout hoặc khóa tài khoản → thu hồi refresh token, user bị đăng xuất trong tối đa thời gian sống của access token.
- Refresh token rotation: mỗi lần refresh cấp refresh token mới và vô hiệu cái cũ. Nếu một refresh token đã dùng rồi bị dùng lại → dấu hiệu bị đánh cắp → thu hồi toàn bộ chuỗi token của phiên đó (reuse detection).
4.2. Các kỹ thuật bổ sung#
| Kỹ thuật | Cách làm | Đánh đổi |
|---|---|---|
Denylist theo jti |
Lưu jti của token bị thu hồi vào Redis với TTL = thời gian còn lại của token |
Mỗi request phải tra Redis → mất một phần lợi ích stateless, nhưng denylist thường rất nhỏ |
| Token version | Lưu tokenVersion của user trong DB/cache, nhúng vào token; tăng version khi đổi mật khẩu hoặc “logout all” |
Cần tra cứu version |
| Opaque token + introspection | Token là chuỗi ngẫu nhiên, service gọi Auth Server để hỏi (RFC 7662) | Thực chất là quay về stateful, tốn network call |
Nhận xét: mọi giải pháp thu hồi đều thêm state trở lại. Câu hỏi thực tế không phải “stateless hay stateful” mà là “chấp nhận bao nhiêu state, ở đâu”.
5. Lưu token ở đâu phía client?#
| Nơi lưu | Rủi ro chính | Phòng thủ |
|---|---|---|
localStorage / sessionStorage |
XSS: script độc đọc được token và gửi đi | Chống XSS triệt để, CSP. Không có cách nào bảo vệ token nếu đã bị XSS |
Cookie HttpOnly |
CSRF: browser tự gửi cookie khi site khác gửi request | SameSite=Lax/Strict, CSRF token, kiểm tra Origin |
| Memory (biến JavaScript) | Mất khi reload trang | Kết hợp refresh token trong cookie HttpOnly |
Thuộc tính cookie cần nhớ:
HttpOnly: JavaScript không đọc được → chống đánh cắp qua XSS.Secure: chỉ gửi qua HTTPS.SameSite:Strict(không gửi trong request cross-site),Lax(gửi khi điều hướng top-level bằng GET — mặc định của các browser hiện đại),None(luôn gửi, bắt buộc kèmSecure).
Khuyến nghị cho SPA hiện nay: pattern BFF (Backend for Frontend) — backend giữ token, browser chỉ có session cookie HttpOnly với BFF. Kết hợp ưu điểm của cả hai: browser dùng session an toàn, còn các service phía sau dùng JWT.
flowchart LR
B[Browser SPA] -- "Cookie HttpOnly session" --> BFF[BFF / API Gateway]
BFF -- "Bearer JWT" --> S1[Order Service]
BFF -- "Bearer JWT" --> S2[User Service]
S1 -- "Bearer JWT" --> S2
Với mobile app: lưu token trong secure storage của hệ điều hành (iOS Keychain, Android Keystore).
6. Lỗ hổng JWT thường gặp#
alg: none: chấp nhận token không có chữ ký. Luôn cố định danh sách thuật toán được chấp nhận phía server, không tin vào header.- Algorithm confusion: server dùng RS256, attacker đổi header sang HS256 và ký bằng public key (vốn công khai) như thể đó là HMAC secret. Library cấu hình sai sẽ chấp nhận. Phòng thủ: như trên.
- Secret HMAC yếu: secret ngắn có thể bị brute-force offline từ một token bất kỳ. Dùng secret ngẫu nhiên đủ dài (ít nhất 256 bit) hoặc chuyển sang RS256/ES256.
- Không kiểm tra claims: bỏ qua
exp,iss,aud→ token hết hạn hoặc token dành cho service khác vẫn được chấp nhận. - Dữ liệu nhạy cảm trong payload: nhắc lại — payload đọc được.
- Token sống quá lâu và không có cơ chế thu hồi.
- Log token: token nằm trong log, URL query string, error message → ai đọc log cũng chiếm được phiên.
Với session, các lỗi tương ứng: session fixation (không đổi session ID sau khi đăng nhập — Spring Security xử lý sẵn), session ID dễ đoán, không đặt timeout, thiếu CSRF protection.
7. Triển khai với Spring Security#
7.1. Session-based (mặc định)#
@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.sessionManagement(s -> s
.sessionFixation(f -> f.changeSessionId()) // mặc định, chống session fixation
.maximumSessions(1)) // giới hạn số phiên đồng thời
.build(); // CSRF bật mặc định
}
Scale với Redis: thêm spring-session-data-redis và cấu hình kết nối Redis.
7.2. JWT — Resource Server#
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.csrf(c -> c.disable()) // hợp lý vì token gửi qua header, không phải cookie
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.shop.com # tự lấy JWKS và kiểm tra iss
audiences: order-service
Ưu tiên dùng oauth2-resource-server có sẵn và một Authorization Server chuẩn (Keycloak, Spring Authorization Server, Auth0, Cognito…) thay vì tự viết filter parse JWT — tự viết là nơi hầu hết lỗ hổng ở phần 6 xuất hiện.
Lưu ý: nếu lưu JWT trong cookie thay vì header, không được tắt CSRF.
8. Chọn cái nào?#
flowchart TD
S([Bắt đầu]) --> Q1{"Client chủ yếu là browser, cùng domain, một backend?"}
Q1 -- Có --> SES["Session + cookie HttpOnly (Redis nếu nhiều instance)"]
Q1 -- Không --> Q2{"Nhiều service cần xác thực, mobile, bên thứ ba, SSO?"}
Q2 -- Có --> Q3{"Có SPA chạy trên browser?"}
Q3 -- Có --> BFF["BFF: session với browser, JWT giữa các service"]
Q3 -- Không --> JWT["JWT access token ngắn hạn + refresh token có rotation"]
Q2 -- Không --> SES
Tóm lại:
- Web app truyền thống / monolith: session. Đơn giản, thu hồi tức thì, framework hỗ trợ tốt.
- API cho mobile, microservices, OAuth2/SSO, service-to-service: JWT (ngắn hạn) + refresh token lưu phía server.
- SPA + microservices: BFF kết hợp cả hai.
- Đừng chọn JWT chỉ vì “stateless nghe hiện đại hơn” — nếu cuối cùng bạn vẫn phải tra Redis mỗi request để kiểm tra denylist, có thể session đã là lựa chọn đơn giản hơn.
9. Câu hỏi phỏng vấn thường gặp#
- Stateful và stateless authentication khác nhau thế nào? → Nơi lưu trạng thái: server vs trong token.
- Cấu trúc JWT gồm những gì? Payload có được mã hóa không? → Header, payload, signature; payload chỉ Base64URL encode.
- Làm sao logout/thu hồi JWT? → Access token ngắn hạn + refresh token server-side, rotation, denylist
jti, token version. - HS256 khác RS256? Microservices nên dùng gì? → Đối xứng vs bất đối xứng; RS256/ES256 với JWKS.
- Nên lưu JWT ở
localStoragehay cookie? → Trade-off XSS vs CSRF; cookieHttpOnly+SameSitehoặc BFF. - Scale session-based auth thế nào? → Centralized store (Spring Session + Redis), tránh phụ thuộc sticky session.
- Tại sao API dùng JWT qua header có thể tắt CSRF? → Browser không tự động gắn header
Authorization; nếu JWT nằm trong cookie thì vẫn cần CSRF protection. - Kể một vài lỗ hổng JWT. →
alg: none, algorithm confusion, secret yếu, không kiểm traexp/aud.