Danh sách bài viết

Bài 78: Implement Session Store Production-grade

Bài thực hành implement session store đủ điều kiện chạy production: thiết kế key schema ba tầng, tạo session ID 256-bit với entropy thực sự ngẫu nhiên, xử lý vòng đời session (create, read, update, delete) bằng pipeline atomic, sliding TTL kết hợp absolute cap, multi-device tracking qua reverse index, force logout toàn bộ thiết bị, session fixation defense khi login, lưu CSRF token per session, throttle last_seen để tránh write storm, graceful degradation khi Redis không phản hồi, và audit log session events.

01/06/2026
18 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

Sau bài này bạn sẽ:

  • Thiết kế được key schema ba tầng cho session store, phân biệt rõ từng tầng dùng để làm gì.
  • Tạo session ID 256-bit với secrets.token_urlsafe, hiểu vì sao UUID v1/v4 không đủ.
  • Implement đầy đủ create, read, update, delete session bằng pipeline atomic.
  • Xử lý TTL hai tầng: sliding TTL cho activity và absolute cap cho lifetime tối đa.
  • Implement reverse index user:sessions:{user_id} để revoke toàn bộ session một user.
  • Xử lý session fixation, CSRF token per session, IP binding, UA fingerprinting.
  • Graceful degrade khi Redis down thay vì block toàn bộ user.
2

Key Schema Thiết Kế

Session store production cần ba tầng key riêng biệt, mỗi tầng phục vụ một mục đích khác nhau:

session:{session_id}            # Hash: dữ liệu chính của session
user:sessions:{user_id}         # Set: danh sách session_id đang active của user
session:meta:{session_id}       # Hash: dữ liệu phụ (CSRF, cart, device name...)

Tầng 1: session:{session_id}

Hash lưu các field bắt buộc:

FieldTypeMô tả
user_idstringID của user sở hữu session
rolestringRole/permission tại thời điểm login
ipstringIP khi tạo session
user_agentstringUA truncate 200 chars
created_atint (unix)Timestamp tạo session
last_seenint (unix)Lần cuối activity (throttled)
expires_atint (unix)Absolute cap — giới hạn lifetime tuyệt đối

Tầng 2: user:sessions:{user_id}

Redis Set lưu danh sách tất cả session ID đang active của một user. Đây là reverse index — không có nó, để revoke toàn bộ session của một user bắt buộc phải SCAN toàn bộ keyspace (O(N) với N là tổng key trên Redis), không chấp nhận được trong production.

Tầng 3: session:meta:{session_id}

Hash tùy chọn cho dữ liệu phụ: CSRF token, cart ID, device name, flag đặc biệt... Tách riêng để không làm phình Hash chính và có thể delete độc lập.

Lý do dùng Hash thay vì String + JSON: Hash cho phép đọc từng field riêng (HGET), update từng field không cần deserialize/serialize toàn bộ (HSET key field value). Với String, mỗi update cần đọc nguyên blob, parse, modify, serialize lại, ghi — atomic hơn nhưng chi phí CPU và payload lớn hơn nhiều.

3

Session ID Generation

import secrets

def gen_session_id() -> str:
    # 32 bytes = 256 bits entropy
    # token_urlsafe trả về base64 URL-safe: 43 ký tự
    return secrets.token_urlsafe(32)

secrets.token_urlsafe(32) dùng os.urandom() — nguồn entropy từ hệ điều hành (CSPRNG), không phải Mersenne Twister của random module. Kết quả là 43 ký tự base64 URL-safe.

Vì sao không dùng UUID v4?

UUID v4 có 122 bit entropy (6 bit cố định cho version/variant). secrets.token_urlsafe(32) cho đúng 256 bit. Trong thực tế, 122 bit đã đủ khó đoán về mặt toán học, nhưng vấn đề là một số UUID v4 implementation dùng PRNG không đủ chất lượng crypto. secrets module đảm bảo CSPRNG theo PEP 506 (Python 3.6+).

Vì sao không dùng UUID v1?

UUID v1 encode timestamp và MAC address vào ID. Attacker biết thời điểm login có thể thu hẹp không gian tìm kiếm đáng kể. Không dùng cho session ID.

Format và lưu trữ

Session ID gửi cho client qua HttpOnly + Secure + SameSite=Lax cookie. Không nhúng trong URL (URL có thể bị log bởi proxy, access log server). Khi nhận từ request, validate format trước khi lookup Redis — reject nếu không khớp pattern 43 ký tự base64url.

4

Create Session (Login Flow)

import time
import secrets

SESSION_TTL = 3600        # 1 giờ sliding (extend mỗi khi có activity)
MAX_LIFETIME = 86400 * 7  # 7 ngày absolute cap

def create_session(
    redis_client,
    user_id: str,
    role: str,
    ip: str,
    user_agent: str
) -> str:
    sid = gen_session_id()
    now = int(time.time())

    session_data = {
        "user_id": user_id,
        "role": role,
        "ip": ip,
        "user_agent": user_agent[:200],  # truncate để tránh bloat
        "created_at": now,
        "last_seen": now,
        "expires_at": now + MAX_LIFETIME,  # absolute cap
    }

    pipe = redis_client.pipeline()
    pipe.hset(f"session:{sid}", mapping=session_data)
    pipe.expire(f"session:{sid}", SESSION_TTL)          # sliding TTL
    pipe.sadd(f"user:sessions:{user_id}", sid)          # reverse index
    pipe.expire(f"user:sessions:{user_id}", MAX_LIFETIME)
    pipe.execute()

    return sid

Pipeline vs Transaction (MULTI/EXEC)

pipeline() mặc định trong redis-py không phải MULTI/EXEC transaction — nó chỉ batch commands để giảm round-trip. Nếu cần đảm bảo toàn bộ 4 lệnh thành công hoặc không lệnh nào, dùng pipeline(transaction=True) để bật MULTI/EXEC. Với create_session, partial failure (HSET thành công nhưng SADD thất bại) dẫn đến session tồn tại nhưng không có trong reverse index — revoke per user sẽ bỏ sót session này. Trong đa số production stack, transaction=True là an toàn hơn ở đây.

Tại sao cần hai TTL?

SESSION_TTL (sliding) được extend mỗi khi có activity — user đang dùng app thì session không hết hạn. expires_at (absolute cap) là giới hạn cứng: dù user liên tục active, sau 7 ngày phải login lại. Đây là yêu cầu phổ biến của security policy (PCI-DSS, nhiều enterprise policy). Chỉ dùng sliding TTL đơn thuần, một session có thể tồn tại vô hạn.

5

Read Session (Per Request)

def get_session(redis_client, sid: str) -> dict | None:
    if not sid:
        return None

    # Validate format trước khi lookup
    if len(sid) != 43 or not sid.replace("-", "").replace("_", "").isalnum():
        return None

    data = redis_client.hgetall(f"session:{sid}")
    if not data:
        return None

    # Kiểm tra absolute expiry (server-side check)
    if int(data[b"expires_at"]) < time.time():
        destroy_session(redis_client, sid)
        return None

    # Sliding window: extend TTL Redis key
    pipe = redis_client.pipeline()
    pipe.expire(f"session:{sid}", SESSION_TTL)

    # Throttled last_seen update (xem bước 6)
    last_seen = int(data[b"last_seen"])
    if time.time() - last_seen > 30:
        pipe.hset(f"session:{sid}", "last_seen", int(time.time()))

    pipe.execute()

    # Decode bytes key nếu redis-py không decode tự động
    return {k.decode(): v.decode() for k, v in data.items()}

Tại sao cần check expires_at ở application layer?

Redis TTL (sliding) và expires_at (absolute) phục vụ hai mục đích khác nhau. TTL Redis xử lý inactivity timeout — key tự xóa sau X giây không có extend. expires_at là giới hạn tuyệt đối trong dữ liệu session, không bị override bởi sliding. Nếu chỉ dùng sliding TTL Redis, mỗi request extend TTL sẽ kéo dài session vô hạn — expires_at trong Hash là kiểm tra bổ sung ở application layer.

Decode bytes

redis-py trả về bytes mặc định khi không cấu hình decode_responses=True. Nếu khởi tạo redis.Redis(decode_responses=True), bỏ bước decode thủ công.

6

Throttle last_seen — Tránh Write Storm

Nếu mỗi request đều gọi HSET session:{sid} last_seen {now}, với 10.000 concurrent sessions mỗi session 1 req/giây = 10.000 HSET/giây chỉ cho last_seen. Với 100.000 sessions = 100.000 write ops/giây — đây là write amplification không cần thiết.

Pattern throttle: chỉ update khi đã qua ít nhất 30 giây từ lần update trước.

UPDATE_INTERVAL = 30  # giây

# Trong get_session:
last_seen = int(data["last_seen"])
if time.time() - last_seen > UPDATE_INTERVAL:
    redis_client.hset(f"session:{sid}", "last_seen", int(time.time()))

Tradeoff: last_seen chính xác ±30 giây. Với hầu hết use case (hiển thị "active 5 phút trước", audit log), độ chính xác này đủ dùng. Nếu cần chính xác hơn, giảm interval xuống 5–10 giây nhưng tăng write load tương ứng.

Giải pháp thay thế: Dùng Redis INCR + periodic flush — mỗi request INCR counter riêng, một background job flush last_seen vào Hash mỗi 30 giây. Phức tạp hơn nhưng tách biệt write concern hoàn toàn.

7

Destroy Session (Logout)

def destroy_session(redis_client, sid: str) -> None:
    # Đọc user_id để cleanup reverse index
    user_id = redis_client.hget(f"session:{sid}", "user_id")

    pipe = redis_client.pipeline()
    pipe.delete(f"session:{sid}")
    pipe.delete(f"session:meta:{sid}")
    if user_id:
        pipe.srem(f"user:sessions:{user_id}", sid)
    pipe.execute()

Thứ tự quan trọng: Đọc user_id trước khi delete Hash, vì sau khi delete không còn thông tin để cleanup Set. Nếu dùng pipeline transaction (MULTI/EXEC) và đặt HGET trong pipeline, kết quả HGET chỉ available sau EXEC — không dùng được để tham chiếu trong cùng pipeline. Giải pháp: HGET riêng (1 round-trip) rồi mới pipeline DELETE + SREM.

Xử lý session đã expire: Nếu session key đã bị Redis xóa tự động (TTL hết), user_id trả về None. Code trên handle đúng — chỉ gọi SREM khi có user_id. Tuy nhiên, reverse Set vẫn có thể chứa sid đã expire (stale member) — xem bước 8 để cleanup.

8

List Active Sessions Của User

def list_user_sessions(redis_client, user_id: str) -> list[dict]:
    sids = redis_client.smembers(f"user:sessions:{user_id}")
    sessions = []
    stale = []

    for sid in sids:
        sid_str = sid.decode() if isinstance(sid, bytes) else sid
        data = redis_client.hgetall(f"session:{sid_str}")
        if data:
            sessions.append({
                "sid": sid_str,
                **{k.decode(): v.decode() for k, v in data.items()}
            })
        else:
            # session key đã expire, Set member còn đó → stale
            stale.append(sid_str)

    # Cleanup stale members theo batch
    if stale:
        pipe = redis_client.pipeline()
        for s in stale:
            pipe.srem(f"user:sessions:{user_id}", s)
        pipe.execute()

    return sessions

Stale Set member

Redis Set user:sessions:{user_id} lưu session ID. Khi session Hash expire (Redis TTL), key session:{sid} bị xóa nhưng Set không tự cập nhật — sid vẫn còn trong Set. Đây là stale member. Nếu không cleanup, SMEMBERS trả về cả stale sids và HGETALL trả về empty dict cho từng cái. Đây là "lazy cleanup" — cleanup xảy ra lúc user xem danh sách session.

Khi nào dùng chức năng này?

Trang "Quản lý thiết bị" — hiển thị danh sách session đang active kèm IP, UA, last_seen. User có thể revoke từng thiết bị cụ thể.

9

Revoke All Sessions (Force Logout)

def revoke_all_sessions(redis_client, user_id: str) -> int:
    """
    Xóa toàn bộ session của user. Trả về số session đã revoke.
    Use case: đổi mật khẩu, tài khoản bị compromise, admin force logout.
    """
    sids = redis_client.smembers(f"user:sessions:{user_id}")
    if not sids:
        return 0

    pipe = redis_client.pipeline()
    for sid in sids:
        sid_str = sid.decode() if isinstance(sid, bytes) else sid
        pipe.delete(f"session:{sid_str}")
        pipe.delete(f"session:meta:{sid_str}")
    pipe.delete(f"user:sessions:{user_id}")
    pipe.execute()

    return len(sids)

Use cases

  • Đổi mật khẩu: Sau khi đổi password, revoke toàn bộ session cũ. Session mới tạo sau khi đổi password.
  • Tài khoản bị compromise: Admin trigger revoke, user phải login lại và verify qua 2FA.
  • Đăng nhập ở thiết bị mới muốn kick thiết bị cũ: User chọn "đăng xuất tất cả thiết bị khác" — revoke all rồi tạo lại session hiện tại.

Quy mô

Pipeline DELETE cho N sessions là O(N) lệnh Redis, nhưng tất cả được batch trong một round-trip. Với user thông thường có 1–5 sessions, không đáng kể. Với user có hàng trăm sessions (bất thường — nên có giới hạn tối đa sessions per user), pipeline vẫn hiệu quả hơn N round-trips riêng lẻ.

10

Session Fixation Defense

Session fixation: Attacker tạo session ID trước, nhử nạn nhân dùng session ID đó để login. Sau khi nạn nhân authenticate, attacker dùng cùng session ID để truy cập với quyền của nạn nhân.

Defense: Sau khi login thành công, luôn tạo session ID mới — không tái sử dụng session ID cũ (kể cả session guest/anonymous).

def login(redis_client, old_sid: str | None, credentials: dict) -> str:
    """
    Xác thực credentials, destroy session cũ, tạo session mới với ID mới.
    """
    user = verify_credentials(credentials)  # raise nếu invalid
    ip = credentials.get("ip", "unknown")
    ua = credentials.get("user_agent", "unknown")

    if old_sid:
        # Migrate data từ old session nếu cần (vd: guest cart)
        migrate_session_data(redis_client, old_sid, pending_new_sid=None)
        destroy_session(redis_client, old_sid)

    new_sid = create_session(redis_client, user.id, user.role, ip, ua)
    return new_sid

Guest session và migration

Nếu app có session guest (giỏ hàng chưa đăng nhập), cần migrate dữ liệu từ guest session sang authenticated session trước khi destroy. Quy trình: đọc session:meta:{old_sid}, ghi vào session:meta:{new_sid}, rồi mới destroy old session.

Chú ý: sau khi phát cookie new_sid, phải đảm bảo cookie cũ (old_sid) bị ghi đè bởi cookie mới trong response — set cookie với cùng tên, path, domain.

11

CSRF Token Per Session

def get_csrf_token(redis_client, sid: str) -> str:
    """
    Lấy CSRF token cho session. Tạo mới nếu chưa có.
    """
    token = redis_client.hget(f"session:meta:{sid}", "csrf")
    if token:
        return token.decode() if isinstance(token, bytes) else token

    # Tạo token mới
    token = secrets.token_urlsafe(32)
    # Chỉ set nếu key chưa tồn tại để tránh race condition
    pipe = redis_client.pipeline()
    pipe.hsetnx(f"session:meta:{sid}", "csrf", token)
    # TTL của meta key đồng bộ với session chính
    pipe.expire(f"session:meta:{sid}", MAX_LIFETIME)
    results = pipe.execute()

    # Nếu race condition: HSETNX trả 0 (key đã được set bởi request khác)
    if not results[0]:
        # Đọc lại giá trị thật
        token = redis_client.hget(f"session:meta:{sid}", "csrf")
        return token.decode() if isinstance(token, bytes) else token

    return token

def verify_csrf_token(redis_client, sid: str, submitted_token: str) -> bool:
    """
    Verify CSRF token từ header X-CSRF-Token.
    Dùng hmac.compare_digest để tránh timing attack.
    """
    import hmac
    stored = redis_client.hget(f"session:meta:{sid}", "csrf")
    if not stored:
        return False
    stored_str = stored.decode() if isinstance(stored, bytes) else stored
    return hmac.compare_digest(stored_str, submitted_token)

hmac.compare_digest so sánh constant-time — tránh timing attack khi attacker đo thời gian response để đoán ký tự đúng từng byte.

Frontend gửi CSRF token qua header X-CSRF-Token với mỗi state-changing request (POST, PUT, DELETE). Server đọc session, lấy stored token, compare. SameSite cookie giảm nguy cơ CSRF thêm một lớp nhưng không thay thế CSRF token hoàn toàn vì SameSite=Lax vẫn allow GET navigation và một số browser không hỗ trợ đầy đủ.

12

IP Binding & User-Agent Fingerprinting

IP Binding

def check_ip_binding(session_data: dict, current_ip: str) -> str:
    """
    Trả về: 'ok', 'warn', 'revoke'
    """
    stored_ip = session_data.get("ip", "")
    if stored_ip == current_ip:
        return "ok"
    # IP thay đổi: log và cảnh báo
    return "warn"  # hoặc "revoke" tùy policy

Strict mode (revoke khi IP đổi): Phù hợp cho app ngân hàng, admin panel. Vấn đề: mobile network, DHCP, VPN, load balancer — IP thay đổi là bình thường. Strict mode sẽ logout user hợp lệ liên tục.

Mild mode (log + warn): Ghi nhận IP mới, hiển thị cảnh báo, không logout. Cân bằng giữa security và UX tốt hơn cho consumer app.

Recommendation: Không enforce strict IP binding trừ khi có yêu cầu compliance cụ thể. Thay vào đó, kết hợp với re-auth trigger cho high-risk action (chuyển tiền, đổi mật khẩu).

User-Agent Fingerprinting

import hashlib

def ua_fingerprint(user_agent: str) -> str:
    # Hash để tiết kiệm storage, không lưu full UA
    return hashlib.sha256(user_agent.encode()).hexdigest()[:16]

def check_ua(session_data: dict, current_ua: str) -> bool:
    stored_fp = session_data.get("ua_fp", "")
    current_fp = ua_fingerprint(current_ua)
    return stored_fp == current_fp

Limitation: Browser tự động update UA khi update version. Nếu Chrome update từ 124 → 125 giữa session, UA fingerprint thay đổi dù là người dùng hợp lệ. Không dùng UA fingerprint làm lý do revoke tự động. Dùng để log, phát hiện anomaly, và yêu cầu re-auth thủ công nếu suspicious.

13

Atomic Update Với Lua (Optimistic Locking)

Với trường hợp cần cập nhật session có điều kiện — ví dụ: chỉ update role nếu session chưa bị revoke bởi request khác — dùng Lua script để đảm bảo atomic read-then-write.

UPDATE_ROLE_SCRIPT = """
-- KEYS[1] = session:{sid}
-- ARGV[1] = expected version
-- ARGV[2] = new role
-- ARGV[3] = new version

local current_version = redis.call("HGET", KEYS[1], "version")
if not current_version then
    return {err="session_not_found"}
end
if current_version ~= ARGV[1] then
    return {err="version_conflict"}
end
redis.call("HSET", KEYS[1], "role", ARGV[2], "version", ARGV[3])
return 1
"""

def update_role_atomic(redis_client, sid: str, expected_version: str, new_role: str) -> bool:
    script = redis_client.register_script(UPDATE_ROLE_SCRIPT)
    new_version = str(int(expected_version) + 1)
    result = script(keys=[f"session:{sid}"], args=[expected_version, new_role, new_version])
    return result == 1

Lua script thực thi atomic trên Redis single-threaded core — không có interleave giữa đọc và ghi. Đây là optimistic locking: nếu version không khớp (concurrent update xảy ra), thao tác trả về conflict thay vì ghi đè.

Thực tế, cần thêm field version vào session Hash khi tạo (khởi tạo bằng 1). Chỉ cần cho critical update, không cần cho last_seen hay TTL extend thông thường.

14

Graceful Degradation Khi Redis Down

Redis không phải hệ thống không bao giờ fail. Network hiccup, restart, memory OOM — tất cả có thể xảy ra. Không có chiến lược fallback, một Redis hiccup 5 giây logout toàn bộ user.

Chiến lược 1: In-process cache ngắn

from functools import lru_cache
import time

# Cache session data trong bộ nhớ process tối đa 60 giây
_local_cache: dict[str, tuple[dict, float]] = {}
LOCAL_CACHE_TTL = 60  # giây

def get_session_with_fallback(redis_client, sid: str) -> dict | None:
    try:
        data = get_session(redis_client, sid)
        if data:
            _local_cache[sid] = (data, time.time())
        return data
    except Exception:
        # Redis down: fallback local cache
        entry = _local_cache.get(sid)
        if entry:
            cached_data, cached_at = entry
            if time.time() - cached_at < LOCAL_CACHE_TTL:
                return cached_data
            else:
                del _local_cache[sid]
        return None

Giới hạn: In-process cache không chia sẻ giữa các instance. Nếu app chạy 10 instance, logout/revoke chỉ xóa cache của instance xử lý request đó, các instance khác vẫn có bản cũ trong local cache tối đa 60 giây. Chấp nhận được cho hiccup ngắn, không chấp nhận được cho revoke security-critical.

Chiến lược 2: Circuit breaker + JWT short token

Khi circuit breaker phát hiện Redis down (N consecutive failures): phát JWT signed (HS256 hoặc RS256) với expiry ngắn (5–10 phút). App verify JWT locally mà không cần Redis. Khi Redis phục hồi, circuit breaker closed lại, quay về session store bình thường.

JWT fallback không support revoke trong thời gian Redis down — đây là tradeoff đã biết. Ghi nhận tất cả JWT được phát khi Redis down để audit sau.

Chiến lược 3: Serve read-only, block write

Khi Redis down: cho phép request read (GET) với session local cache, reject tất cả state-changing request (POST/PUT/DELETE) trả về 503 với message "hệ thống tạm không nhận thao tác, vui lòng thử lại sau". Đơn giản nhất, phù hợp khi downtime ngắn và predictable.

15

Audit Log & Session Metrics

Audit log

import logging

audit_logger = logging.getLogger("session.audit")

def log_session_event(event: str, sid: str, user_id: str, extra: dict = None):
    """
    event: session_created | session_read | session_destroyed |
           sessions_revoked | session_expired | login_failed
    """
    record = {
        "event": event,
        "sid": sid[:8] + "...",  # partial SID — không log full SID
        "user_id": user_id,
        "timestamp": int(time.time()),
        **(extra or {}),
    }
    audit_logger.info(record)

Quan trọng: không log full session ID. Session ID là credential — log đầy đủ vào file log tương đương lưu password plaintext trong log. Log 8 ký tự đầu đủ để trace trong investigation mà không expose full token.

Events cần log: session_created (kèm IP, UA), session_destroyed (logout), sessions_revoked (force logout, kèm lý do), session_expired (expired khi validate), session_fixation_defense (old sid destroyed on login).

Session metrics

Các metric cần theo dõi:

  • Active session count: Không dùng DBSIZE (count tất cả key Redis, không phân biệt type). Dùng application-level counter hoặc aggregate SCARD user:sessions:{user_id} theo sampling.
  • Session creation rate: Số login thành công mỗi phút — spike bất thường có thể là credential stuffing.
  • Failed session lookup rate: HGETALL trả về empty — SID không tồn tại. Tỷ lệ cao có thể là brute force session ID hoặc bug client gửi expired SID.
  • Session duration: destroyed_at - created_at, ghi vào metric khi destroy. Distribution histogram để tune TTL policy.
16

Memory Budget

Thành phầnSize ước tính
Key session:{sid} (43 chars)~55 bytes
Hash overhead Redis (listpack encoding)~128 bytes
7 fields × ~30 bytes mỗi field~210 bytes
Key user:sessions:{user_id}~40 bytes
Set member (SID per device)~55 bytes mỗi SID
Tổng per session (1 device)~500–700 bytes

Với session:meta bổ sung (CSRF + vài field): ~300 bytes thêm. Tổng ~1KB/session là estimate an toàn.

Concurrent sessionsMemory ước tính
100.000~100 MB
1.000.000~1 GB
10.000.000~10 GB

10 triệu concurrent session trên single Redis instance (32–64 GB RAM) vẫn feasible nếu không có hot key problem. Nếu vượt quá, Redis Cluster sharding theo user_id hoặc session_id prefix.

Giới hạn max session per user (ví dụ: 10 thiết bị) để tránh tấn công tạo session vô hạn cho một user_id, làm phình Set và tốn memory.

17

Anti-patterns & Best Practices

Anti-patterns

  • Session ID predictable: UUID v1 (encode timestamp), sequential counter, MD5(user_id + timestamp). Dùng secrets.token_urlsafe(32).
  • Lưu password / raw token trong session: Session trong Redis có thể bị dump nếu Redis bị compromise. Chỉ lưu user_id, role, metadata cần thiết.
  • Không reverse index: Không có user:sessions:{user_id}, revoke per user buộc phải SCAN toàn keyspace — O(N) total keys, không dùng được trong production.
  • Update last_seen mỗi request không throttle: 10k sessions × 10 req/s = 100k HSET/giây. Throttle 30s interval giảm xuống còn 10k/30 = ~333 writes/giây.
  • Không validate expires_at server-side: Chỉ dựa vào Redis TTL, không check expires_at field — có thể bỏ sót absolute cap nếu TTL bị extend không đúng.
  • Lưu blob lớn trong session: User preferences 100KB, cart items đầy đủ, image binary. Session chỉ lưu ID reference, dữ liệu lưu ở DB hoặc cache riêng.
  • Không log full SID thì lại log partial không đủ: 0 ký tự = không trace được. 8 ký tự đầu là điểm cân bằng thực tế.

Best practices

  • 256-bit random session ID từ CSPRNG (secrets.token_urlsafe(32)).
  • Hash structure cho session data thay vì String+JSON.
  • Reverse index user:sessions:{user_id} cho revoke per user.
  • Sliding TTL + absolute cap (expires_at field).
  • Throttle last_seen update (30s interval).
  • Rotate session ID sau khi authenticate (session fixation defense).
  • HttpOnly + Secure + SameSite=Lax cookie cho session ID.
  • CSRF token per session với hmac.compare_digest.
  • Graceful degradation khi Redis down — không block toàn bộ user.
  • Audit log session events (partial SID, không full SID).
  • Giới hạn max concurrent session per user.
  • Pipeline transaction cho create/destroy để tránh partial state.
18

Tổng Kết & Quiz

Tổng kết

  • Key schema ba tầng: session:{sid} (data chính), user:sessions:{uid} (reverse index), session:meta:{sid} (data phụ).
  • Session ID: secrets.token_urlsafe(32) = 256-bit, 43 ký tự base64 URL-safe.
  • TTL hai tầng: sliding (EXPIRE Redis extend per request) + absolute cap (expires_at field check ở application).
  • Reverse index là điều kiện cần thiết để revoke per user — không có thì phải SCAN.
  • Throttle last_seen 30s để tránh write storm.
  • Session fixation: luôn tạo SID mới sau authenticate, destroy SID cũ.
  • Graceful degradation: in-process cache ngắn hoặc JWT fallback khi Redis down.
  • Không log full SID — partial SID (8 ký tự) đủ cho audit trail.

Quiz 5 câu

  1. Tại sao cần user:sessions:{user_id} Set riêng thay vì chỉ dùng session:{sid} Hash? Nếu không có nó, làm thế nào để revoke toàn bộ session của user?
  2. Phân biệt sliding TTL (Redis EXPIRE) và absolute cap (expires_at field). Trường hợp nào chỉ dùng sliding TTL đơn thuần gây vấn đề?
  3. Session fixation attack là gì và bài này defend bằng cách nào? Cần thêm bước gì nếu app có guest session với dữ liệu (ví dụ giỏ hàng)?
  4. Vì sao cần throttle last_seen update? Với 50.000 active sessions, mỗi session 5 req/giây, tính số HSET/giây khi throttle 30s so với update mỗi request.
  5. Nêu ba chiến lược graceful degradation khi Redis down cho session store. Chiến lược nào không support revoke trong thời gian Redis down và tại sao?

Đáp án gợi ý

  1. Set reverse index cho phép lấy tất cả SID của user bằng SMEMBERS O(1) trên số session (thường 1–10). Không có nó, buộc phải SCAN 0 MATCH session:* COUNT 100 duyệt toàn bộ keyspace — O(N) tổng số key, không chấp nhận được khi Redis có hàng triệu key.
  2. Sliding TTL: mỗi request extend TTL Redis, user đang dùng app thì không bị logout. Absolute cap: dù user liên tục active, sau 7 ngày phải login lại. Chỉ sliding thuần: user vào app 1 lần/ngày = session không bao giờ expire — vi phạm security policy yêu cầu re-auth định kỳ.
  3. Attacker tạo SID trước, nhử victim dùng SID đó login. Sau khi victim authenticate, attacker dùng SID để truy cập. Defense: destroy old SID, tạo SID mới sau login. Guest migration: đọc session:meta:{old_sid} (cart data), ghi vào session:meta:{new_sid}, sau đó mới destroy old session.
  4. Không throttle: 50.000 × 5 = 250.000 HSET/giây. Throttle 30s: trong 30 giây, mỗi session tối đa 1 update = 50.000 / 30 ≈ 1.667 HSET/giây — giảm 150 lần.
  5. Ba chiến lược: (a) In-process cache 60s, (b) Circuit breaker + JWT fallback, (c) Read-only mode block write. JWT fallback không support revoke vì JWT là stateless — không có Redis để lookup blacklist, revoke chỉ hiệu lực sau khi JWT expire.

Bài tiếp theo

Bài 79 đào sâu vào TTL strategies: so sánh chi tiết absolute expiry, sliding window và hybrid — khi nào dùng cái nào, các edge case khi TTL không đồng bộ giữa Hash data và Redis key expiry.

Tham khảo