JWT vs Session: Stateless và Stateful authentication

“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ớ:

  1. 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).
  2. Cách lưu token quan trọng không kém chọn loại token: localStorage dễ bị XSS, cookie dễ bị CSRF — mỗi lựa chọn có biện pháp phòng thủ riêng.
  3. 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”.

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

2.3. Nhược điểm

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

3.5. Nhược điểm — phần hay bị xem nhẹ


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

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ớ:

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Dữ liệu nhạy cảm trong payload: nhắc lại — payload đọc được.
  6. Token sống quá lâu và không có cơ chế thu hồi.
  7. 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:


9. Câu hỏi phỏng vấn thường gặp

  1. Stateful và stateless authentication khác nhau thế nào? → Nơi lưu trạng thái: server vs trong token.
  2. 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.
  3. Làm sao logout/thu hồi JWT? → Access token ngắn hạn + refresh token server-side, rotation, denylist jti, token version.
  4. HS256 khác RS256? Microservices nên dùng gì? → Đối xứng vs bất đối xứng; RS256/ES256 với JWKS.
  5. Nên lưu JWT ở localStorage hay cookie? → Trade-off XSS vs CSRF; cookie HttpOnly + SameSite hoặc BFF.
  6. Scale session-based auth thế nào? → Centralized store (Spring Session + Redis), tránh phụ thuộc sticky session.
  7. 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.
  8. Kể một vài lỗ hổng JWT. → alg: none, algorithm confusion, secret yếu, không kiểm tra exp/aud.
Back
0%