Là gì
JWT là một token tự mô tả: nó mang cả dữ liệu (claim) lẫn thông tin về cách nó được ký (header alg). Vấn đề nằm ở chỗ đó — một token tự nói cách kiểm nó thì nó cũng tự nói dối được, và phần lớn lỗ hổng JWT là việc thư viện tin header của token thay vì tin cấu hình của bạn.
Vì sao bạn quan tâm
Điều đáng nắm: JWT không sai, cách dùng JWT thường sai — và hai lỗi phổ biến nhất không phải lỗi mật mã mà là lỗi bỏ sót.
- Không kiểm
alg. Thư viện đọcalgtừ token và chọn thuật toán theo đó.alg: none(chấp nhận token không ký) và algorithm confusion (đưa public key RSA vào hàm verify HMAC, nên public key trở thành secret) đều từ đây. - Không kiểm
audvàiss. Token do đúng identity provider của bạn ký, cho một ứng dụng KHÁC, vẫn verify thành công nếu bạn chỉ kiểm chữ ký. Trong một hệ nhiều service dùng chung IdP, đây là leo thang quyền ngang giữa các service.
Và một điều về kiến trúc quan trọng hơn cả hai: JWT không thu hồi được. Đó là toàn bộ giá trị của nó (verify không cần gọi DB) và cũng là toàn bộ chi phí. Nên một access token TTL 24 giờ nghĩa là một tài khoản bị vô hiệu hoá vẫn dùng được 24 giờ. Nếu bạn cần thu hồi được ngay thì bạn cần một session store — và lúc đó JWT không mang lại gì ngoài một payload lớn hơn.
Cộng một lỗi nhận thức phổ biến: JWT không được mã hoá. eyJ... là base64url, không phải mã hoá. Mọi claim trong đó là công khai với bất kỳ ai có token.
Cơ chế hoạt động
Cấu trúc là ba phần base64url nối bằng dấu chấm: header.payload.signature. Lỗ hổng nằm ở việc ai quyết định thuật toán verify.
flowchart TD T["Token: header.payload.signature<br/>header = #quot;alg#quot;: #quot;RS256#quot;"] --> Q{Thuật toán verify<br/>chọn từ đâu?} Q -->|"Từ header của TOKEN"| B1["alg: none → chấp nhận không ký"] Q -->|"Từ header của TOKEN"| B2["alg: HS256 + public key RSA<br/>làm secret HMAC"] B1 --> X["🔓 Giả mạo claim tuỳ ý"] B2 --> X Q -->|"Từ CẤU HÌNH của server"| G["Chỉ RS256, chỉ key này"] G --> V{Kiểm tiếp aud, iss, exp} V -->|"đủ"| OK["✅ Nhận"] V -->|"thiếu aud/iss"| L["⚠ Token của app khác đi qua"]Algorithm confusion đáng giải thích kỹ vì nó là lỗ hổng phản trực giác nhất: server cấu hình RS256, kẻ tấn công đổi header thành HS256 và ký token bằng chính public key của server (public key là công khai — nó nằm ở /.well-known/jwks.json). Nếu thư viện chọn thuật toán theo alg của token, nó sẽ gọi HMAC-verify(token, publicKey) — và phép đó khớp, vì kẻ tấn công dùng đúng chuỗi đó làm secret.
Bảy phép kiểm, và bốn hay bị bỏ:
| Kiểm | Bỏ thì sao |
|---|---|
| Chữ ký | Giả mạo mọi claim |
alg theo allowlist của server | alg: none, algorithm confusion |
exp | Token sống mãi |
aud | Token của app khác trong cùng IdP đi qua |
iss | Token do IdP khác ký đi qua |
kid phải trong JWKS đã tin | kid trỏ tới một URL của kẻ tấn công (jku/x5u) |
nbf | Token dùng được trước thời điểm hiệu lực |
kid đáng nói riêng: một số thư viện đọc jku (JWK Set URL) từ header và tải key từ đó. Kẻ tấn công trỏ jku tới server của họ, và chữ ký khớp với key của họ.
Mô tả sơ đồ: Sơ đồ quyết định cho việc verify một JWT. Câu hỏi trung tâm là thuật toán verify được chọn từ đâu. Nếu chọn từ header của chính token thì có hai đường tấn công: alg none làm server chấp nhận token không ký, và alg HS256 với public key RSA dùng làm secret HMAC — cả hai cho phép giả mạo claim tuỳ ý. Nếu chọn từ cấu hình của server thì token đi tiếp tới các phép kiểm aud, iss và exp; đủ thì nhận, còn thiếu aud hoặc iss thì token của một ứng dụng khác trong cùng identity provider vẫn đi qua.
Ví dụ cụ thể
# Token gốc, đã giải mã. Chú ý: base64url, KHÔNG mã hoá — mọi claim là công khai.echo 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ' | base64 -d# {"alg":"RS256","kid":"1"}// ① alg: none — chữ ký rỗng, và một số thư viện chấp nhận.{"alg":"none","typ":"JWT"}{"sub":"1042","role":"admin","exp":9999999999}// Token: eyJhbGciOiJub25lIn0.eyJzdWIiOiIxMDQyIiwicm9sZSI6ImFkbWluIn0.// ↑ không có gì# ② Algorithm confusion. Public key là CÔNG KHAI — nó ở /.well-known/jwks.json.curl -s https://app.example/.well-known/jwks.json > jwks.json # Đổi alg thành HS256 và ký bằng chính public key làm secret HMAC.python3 - <<'EOF'import jwtpub = open("public.pem").read()print(jwt.encode({"sub":"1042","role":"admin"}, pub, algorithm="HS256"))EOFServer cấu hình RS256. Nếu nó chọn thuật toán theo alg của token thì nó gọi HMAC-verify(token, publicKey) — và phép đó khớp.
// ③ Không kiểm aud. Token này do ĐÚNG IdP của bạn ký, cho một app khác.{"iss":"https://id.acme.com","aud":"internal-admin-tool","sub":"7","role":"admin"}// Chữ ký hợp lệ. Nếu bạn chỉ kiểm chữ ký, nó đi qua — và "role":"admin"// có nghĩa trong app của bạn.// ④ jku — thư viện tải key từ URL trong header.{"alg":"RS256","jku":"https://evil.example/jwks.json"}// Chữ ký khớp key của kẻ tấn công, vì server đi lấy key của kẻ tấn công.builder.Services.AddAuthentication().AddJwtBearer(o =>{ o.TokenValidationParameters = new TokenValidationParameters { IssuerSigningKey = publicKey, ValidateIssuerSigningKey = true, // ❌ Không có ValidAlgorithms. Thư viện chọn thuật toán theo alg CỦA TOKEN, // nên alg: none và algorithm confusion đều đi qua. Một dòng thiếu, hai lỗ. // ❌ Tắt vì "IdP đổi domain, sửa sau". Token do IdP khác ký đi qua. ValidateIssuer = false, // ❌ Tắt vì "chúng ta chỉ có một app". Rồi công ty có năm app cùng IdP, và // token của app ít quan trọng nhất được app quan trọng nhất chấp nhận. ValidateAudience = false, // ❌ ClockSkew mặc định là 5 PHÚT và không ai nhận ra: nó cộng thẳng vào TTL, // nên một token 5 phút thật ra sống 10 phút. };}); // ❌ Và phân quyền dựa vào payload GIẢI MÃ Ở CLIENT.// Payload chưa verify là dữ liệu của kẻ tấn công.// if (jwtDecode(token).role === "admin") showAdminPanel(); // ❌ TTL 24 giờ. JWT không thu hồi được, nên đó là 24 giờ mà một tài khoản đã bị// khoá vẫn hoạt động bình thường.var token = new JwtSecurityToken( claims: [new Claim("sub", user.Id.ToString()), new Claim("role", user.Role.ToString()), new Claim("email", user.Email)], // ❌ PII trong payload công khai expires: DateTime.UtcNow.AddHours(24), signingCredentials: creds);import jwt from "jsonwebtoken"; app.use((req, res, next) => { const token = req.headers.authorization?.replace("Bearer ", ""); // ❌ Không truyền algorithms, không truyền audience, không truyền issuer. // Thư viện chọn thuật toán theo alg CỦA TOKEN — đây chính là CVE-2015-9235, // và nguyên nhân gốc là API cho phép bỏ qua tham số đó. const claims = jwt.verify(token, PUBLIC_KEY); req.user = claims; next();}); // ❌ Và phân quyền ở client dựa vào payload chưa verify:// const { role } = jwtDecode(localStorage.getItem("token"));// if (role === "admin") renderAdminPanel();Chuyện đã xảy ra
CVE-2015-9235 — node-jsonwebtoken và cả một họ thư viện. Hàm verify() chọn thuật toán theo alg của token, nên alg: none và algorithm confusion đều hoạt động. Cùng lỗi xuất hiện trong thư viện của Python, Ruby, PHP, Java trong cùng giai đoạn — nguyên nhân chung là API cho phép không truyền danh sách thuật toán. Bài học về API design: một tham số bắt buộc sẽ không bị quên; một tham số tuỳ chọn sẽ bị quên.
CVE-2022-21449 "Psychic Signatures" — Java 15–18. Kiểm chữ ký ECDSA nhận một chữ ký (r=0, s=0) là hợp lệ với mọi message và mọi key. Nghĩa là một token có chữ ký toàn số 0 verify thành công. Đáng nhớ vì nó cho thấy lỗi có thể nằm ở tầng dưới thư viện JWT — và không phép kiểm nào ở tầng ứng dụng bắt được nó, chỉ có việc biết mình chạy phiên bản nào.
Và một dạng lặp lại trong bug bounty: không kiểm aud. Nhiều báo cáo mô tả cùng hình dạng — một tổ chức có nhiều app dùng chung một IdP, và token của app ít quan trọng nhất được nhận bởi app quan trọng nhất.
Cách phòng chống
Allowlist thuật toán ở SERVER, và bảy phép kiểm đầy đủ
bắt buộcNguyên tắc một dòng: thuật toán và key đến từ cấu hình của bạn, không từ token.
new TokenValidationParameters{ ValidAlgorithms = ["RS256"], // allowlist, không đọc alg của token ValidateIssuer = true, ValidIssuer = "https://id.acme.com", ValidateAudience = true, ValidAudience = "seclab-api", ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30), ValidateIssuerSigningKey = true, RequireSignedTokens = true, // đóng alg: none RequireExpirationTime = true,}ValidAlgorithms là dòng quan trọng nhất và nó đóng cả alg: none lẫn algorithm confusion trong một chỗ. ClockSkew mặc định của Microsoft là 5 phút — quá rộng; đặt 30 giây, và nhớ rằng nó cộng vào TTL thật của token.
aud và iss là hai phép kiểm bị bỏ nhiều nhất, và chúng không phải chi tiết: trong một tổ chức có nhiều app dùng chung IdP, thiếu aud nghĩa là mọi app tin token của mọi app khác.
Và jku/x5u phải bị bỏ qua hoàn toàn. Không bao giờ tải key từ URL trong token. JWKS lấy từ endpoint đã cấu hình, cache theo kid, và một kid lạ là từ chối — không phải đi tìm.
builder.Services.AddAuthentication().AddJwtBearer(o =>{ // JWKS lấy từ endpoint ĐÃ CẤU HÌNH và cache. Không bao giờ từ jku/x5u trong // header của token — đó là để kẻ tấn công chỉ cho ta đi lấy key của họ. o.Authority = builder.Configuration["Auth:Issuer"]; o.MetadataAddress = $"{builder.Configuration["Auth:Issuer"]}/.well-known/openid-configuration"; o.RequireHttpsMetadata = true; o.TokenValidationParameters = new TokenValidationParameters { // ── Dòng quan trọng nhất của cả file ──────────────────────────────── // Allowlist ở SERVER. Nó đóng CẢ alg: none LẪN algorithm confusion trong // một chỗ, vì thư viện không còn đọc alg của token để chọn thuật toán. // // Algorithm confusion: kẻ tấn công đổi alg thành HS256 và ký bằng chính // PUBLIC KEY của ta (công khai, ở jwks.json) làm secret HMAC. Nếu thư viện // tin alg thì HMAC-verify(token, publicKey) KHỚP. ValidAlgorithms = [SecurityAlgorithms.RsaSha256], ValidateIssuerSigningKey = true, RequireSignedTokens = true, // đóng alg: none ở một tầng thứ hai RequireExpirationTime = true, ValidateIssuer = true, ValidIssuer = builder.Configuration["Auth:Issuer"], // aud là phép kiểm bị bỏ NHIỀU NHẤT và nó không phải chi tiết: trong một tổ // chức có nhiều app dùng chung IdP, thiếu nó nghĩa là mọi app tin token của // mọi app khác — và chữ ký của chúng đều HỢP LỆ. ValidateAudience = true, ValidAudience = "seclab-api", ValidateLifetime = true, // Mặc định của Microsoft là 5 PHÚT, và nó cộng thẳng vào TTL thật. 30 giây // đủ cho lệch đồng hồ giữa các máy đã đồng bộ NTP. ClockSkew = TimeSpan.FromSeconds(30), }; o.Events = new JwtBearerEvents { // Lớp 3 — client hợp lệ gửi đúng MỘT alg và nó không đổi, nên mọi giá trị // khác là một lần thử. Tín hiệu này gần như không có nhiễu. OnAuthenticationFailed = ctx => { var raw = ctx.Request.Headers.Authorization.ToString().Replace("Bearer ", ""); var dot = raw.IndexOf('.'); if (dot > 0) { try { var header = JsonDocument.Parse( Base64UrlEncoder.Decode(raw[..dot])).RootElement; var alg = header.TryGetProperty("alg", out var a) ? a.GetString() : "?"; var hasJku = header.TryGetProperty("jku", out _) || header.TryGetProperty("x5u", out _); // Log header, KHÔNG log token: token là credential, và log là // nơi credential sống lâu nhất. if (alg != "RS256" || hasJku) ctx.HttpContext.RequestServices .GetRequiredService<ILogger<Program>>() .LogWarning("Token bị từ chối: alg={Alg} jku={HasJku}", alg, hasJku); } catch { /* header không parse được — cũng là một tín hiệu, nhưng không đáng crash */ } } return Task.CompletedTask; }, };}); // ── Phát hành token ────────────────────────────────────────────────────────/// <summary>/// TTL 10 phút vì JWT KHÔNG THU HỒI ĐƯỢC. Đó không phải một tinh chỉnh — đó là hệ/// quả kiến trúc: verify không gọi DB nghĩa là không có gì để hỏi "token này còn/// hiệu lực không". Cửa sổ này là cửa sổ mà một tài khoản đã khoá vẫn hoạt động.////// Refresh token thì lưu Ở SERVER và rotate mỗi lần dùng — nó là phần THU HỒI ĐƯỢC,/// và nó thu hồi được chính vì nó có state./// </summary>public AuthTokens Issue(AppUser user, DateTime now){ var claims = new List<Claim> { new("sub", user.Id.ToString()), new("role", user.Role.ToString()), new("jti", UuidV7.NewGuid().ToString()), // để denylist theo token nếu cần // KHÔNG có email, phone, hay PII nào: eyJ... là base64url, không phải mã hoá. // Mọi claim ở đây công khai với bất kỳ ai có token, kể cả log của một proxy. }; var access = new JwtSecurityToken( issuer: _issuer, audience: "seclab-api", claims: claims, notBefore: now, expires: now.AddMinutes(10), signingCredentials: _creds); // Refresh token: chuỗi ngẫu nhiên opaque, hash lưu trong DB. Không phải JWT — // nó không cần tự mô tả, và nó cần thu hồi được. var refresh = RandomNumberGenerator.GetBytes(32); _sessions.Store(user.Id, SHA256.HashData(refresh), now.AddDays(14)); return new AuthTokens( new JwtSecurityTokenHandler().WriteToken(access), WebEncoders.Base64UrlEncode(refresh));} // ── Lớp 2 · thu hồi theo NGƯỜI, không theo token ───────────────────────────/// <summary>/// Một cột timestamp, và nó xử lý được ba sự kiện chiếm gần hết nhu cầu thu hồi/// thật: đổi mật khẩu, đăng xuất mọi thiết bị, khoá tài khoản.////// Rẻ hơn denylist theo jti (không cần Redis) và đúng hơn về nghĩa: ba sự kiện đó/// thu hồi theo NGƯỜI, không theo một token cụ thể./// </summary>public async Task<bool> IsStillValidAsync(ClaimsPrincipal user, CancellationToken ct){ var iat = long.Parse(user.FindFirstValue("iat")!); var validAfter = await _users.GetTokensValidAfterAsync(user.GetUserId(), ct); return DateTimeOffset.FromUnixTimeSeconds(iat) >= validAfter;}import { createRemoteJWKSet, jwtVerify } from "jose"; // jose thay vì jsonwebtoken: API của nó BẮT BUỘC truyền algorithms, nên không có// đường nào để quên. Đây là khác biệt về API design, và nó là lý do CVE-2015-9235// tồn tại — một tham số tuỳ chọn sẽ bị quên.//// JWKS lấy từ endpoint ĐÃ CẤU HÌNH và cache theo kid. Không bao giờ từ jku trong// header của token.const JWKS = createRemoteJWKSet(new URL(process.env.AUTH_ISSUER + "/.well-known/jwks.json"), { cacheMaxAge: 10 * 60 * 1000, cooldownDuration: 30 * 1000, // chống việc một kid lạ làm ta gọi IdP liên tục}); export async function authenticate(req: Request): Promise<Claims> { const token = req.headers.get("authorization")?.replace(/^Bearer /, ""); if (!token) throw new Unauthorized("missing token"); const { payload, protectedHeader } = await jwtVerify(token, JWKS, { // Allowlist ở SERVER. Đóng cả alg: none lẫn algorithm confusion. algorithms: ["RS256"], // Hai phép kiểm bị bỏ nhiều nhất. Thiếu chúng thì token của một app khác // trong cùng IdP đi qua với chữ ký HỢP LỆ. issuer: process.env.AUTH_ISSUER, audience: "seclab-api", clockTolerance: 30, // giây, không phải 5 phút requiredClaims: ["sub", "exp", "iat", "jti"], }); // Lớp 2 — thu hồi theo NGƯỜI. Một cột timestamp xử lý được đổi mật khẩu, // đăng xuất mọi thiết bị, và khoá tài khoản. const validAfter = await getTokensValidAfter(payload.sub as string); if ((payload.iat as number) < validAfter) throw new Unauthorized("token revoked"); // Lớp 3 — alg lạ là tín hiệu gần như không có nhiễu. Log HEADER, không log token. if (protectedHeader.alg !== "RS256") { req.log.warn({ alg: protectedHeader.alg, kid: protectedHeader.kid }, "alg bất thường"); } return payload as Claims;}TTL ngắn, vì JWT không thu hồi được
bắt buộcĐây là hệ quả kiến trúc, không phải một tinh chỉnh: verify không gọi DB nghĩa là không có gì để hỏi "token này còn hiệu lực không".
- Access token: 5–15 phút. Đây là cửa sổ mà một tài khoản đã bị vô hiệu hoá vẫn hoạt động, và một token bị lộ vẫn dùng được. 24 giờ là 24 giờ đó.
- Refresh token: lưu ở SERVER, và rotate mỗi lần dùng. Refresh token là thứ thu hồi được, và nó chỉ thu hồi được vì nó có state. Rotation cộng phát hiện tái sử dụng: một refresh token đã dùng lại xuất hiện lần hai nghĩa là nó bị đánh cắp — thu hồi cả họ token của phiên đó.
- Refresh token trong cookie
HttpOnly,Secure,SameSite, không tronglocalStorage. Xem topic xss lớp 3.
Và câu hỏi phải trả lời trước khi chọn JWT: bạn có cần thu hồi ngay không? Nếu có (khoá tài khoản, đổi mật khẩu, đăng xuất mọi thiết bị) thì bạn cần một store — và lúc đó một session id opaque đơn giản hơn, nhỏ hơn, và thu hồi được. JWT đáng dùng khi bạn thật sự cần verify không có state, ở nhiều service.
Đừng đặt gì cần bí mật vào payload
eyJ... là base64url, không phải mã hoá. Mọi claim là công khai với bất kỳ ai có token — kể cả JavaScript trên trang, kể cả trong log của một proxy.
Nên payload chỉ nên có: sub, iss, aud, exp, iat, jti, và những claim mà việc công khai chúng là chấp nhận được (role, tenant). Không có email, số điện thoại, PII, hay trạng thái nội bộ.
Và một điểm về kích thước: mỗi claim thêm vào là byte đi kèm mọi request. Một token 4KB vượt giới hạn header của một số proxy, và nó là 4KB × số request.
Khi thật sự cần claim bí mật: JWE (JWT được mã hoá). Nhưng câu hỏi trước đó là vì sao thông tin đó cần đi qua client — thường thì nó không cần.
Danh sách thu hồi cho trường hợp cần huỷ ngay
Lớp này lấy lại một phần khả năng thu hồi mà JWT bỏ đi, và nó là đánh đổi có ý thức: nó thêm một lời gọi Redis vào đường verify, tức là bỏ đi lý do chính để dùng JWT — nhưng chỉ cho những sự kiện thật sự cần.
- Denylist theo
jticho token bị thu hồi cụ thể, TTL bằng TTL còn lại của token (nên danh sách tự dọn và nó nhỏ). - Hoặc rẻ hơn:
tokensValidAftertheo user. Một timestamp trong DB; token cóiattrước mốc đó bị từ chối. Một cột, và nó xử lý được "đổi mật khẩu", "đăng xuất mọi thiết bị", "khoá tài khoản" — ba sự kiện chiếm gần hết nhu cầu thu hồi thật.
Chọn cái thứ hai khi được: nó không cần Redis, và nó thu hồi theo người thay vì theo token — đúng thứ mà các sự kiện trên yêu cầu.
Phát hiện: một `alg` lạ là tín hiệu gần như không có nhiễu
Client hợp lệ của bạn gửi đúng một alg, và nó không đổi. Nên mọi giá trị khác là một lần thử.
Ba tín hiệu, tất cả tính được từ chỗ verify token:
algngoài allowlist —none,HS256khi bạn dùng RS256. Alert lần đầu tiên, không alert theo tổng số.kidkhông có trong JWKS, hoặc header cójku/x5u. Cả hai gần như không xuất hiện trong lưu lượng bình thường.audkhông phải của bạn nhưng chữ ký hợp lệ. Đây là tín hiệu đặc biệt hữu ích: nó nghĩa là có ai đang thử token của một app khác trong cùng tổ chức — và nó cũng có thể là một service của chính bạn đang cấu hình sai.
Log iss, aud, kid, alg của mọi token bị từ chối. Không log token — nó là credential, và log là nơi credential sống lâu nhất.
Kiểm chứng đã vá
1. Test lấy bốn payload ở khối 4 làm dữ liệu vào. Điểm quan trọng: alg: none và algorithm confusion cần test riêng, vì RequireSignedTokens đóng cái đầu và ValidAlgorithms đóng cái sau — bỏ một trong hai thì chỉ một test đỏ. Xem tab csharp / test.
2. Kiểm cấu hình có đủ bảy phép kiểm — đây là phép kiểm bắt được lỗi bỏ sót, và bỏ sót là dạng lỗi chính của topic này:
grep -rn "TokenValidationParameters" -A20 --include='*.cs' src/ > /tmp/tvp.txtfor k in ValidAlgorithms ValidateIssuer ValidateAudience ValidateLifetime \ RequireSignedTokens RequireExpirationTime; do grep -q "$k" /tmp/tvp.txt || echo "THIẾU phép kiểm: $k"done# ClockSkew mặc định của Microsoft là 5 PHÚT — quá rộng, và nó cộng vào TTL thật.grep -q "ClockSkew" /tmp/tvp.txt || echo "THIẾU ClockSkew (mặc định 5 phút)"3. Thử thật trên staging. Đây là phép kiểm duy nhất chứng minh thư viện hành xử như bạn nghĩ:
B=https://staging.example.com# alg: none — phải 401T=$(python3 -c "import jwt;print(jwt.encode({'sub':'1','role':'admin'},None,algorithm='none'))")curl -s -o /dev/null -w 'alg=none → %{http_code}\n' "$B/api/me" -H "Authorization: Bearer $T" # Algorithm confusion — ký bằng public key làm secret HMAC. Phải 401.curl -s "$B/.well-known/jwks.json" | python3 tools/jwk2pem.py > pub.pemT=$(python3 -c "import jwt;print(jwt.encode({'sub':'1','role':'admin'},open('pub.pem').read(),algorithm='HS256'))")curl -s -o /dev/null -w 'confusion → %{http_code}\n' "$B/api/me" -H "Authorization: Bearer $T"4. Kiểm aud bằng một token THẬT của app khác. Phép kiểm này cần một token thật, nên nó thường bị bỏ — và nó là lỗ hổng phổ biến nhất trong tổ chức nhiều app. Lấy token của một app khác cùng IdP và khẳng định nó bị từ chối.
5. Kiểm TTL thật sự ngắn:
T=$(get_access_token)python3 -c "import jwt,sys,datetimec=jwt.decode(sys.argv[1],options={'verify_signature':False})ttl=(c['exp']-c['iat'])/60print(f'TTL {ttl:.0f} phút')sys.exit(0 if ttl<=15 else 1)" "$T" || echo "access token quá dài — JWT không thu hồi được"6. Kiểm không có PII trong payload — token là công khai, và mọi claim trong đó cũng vậy:
python3 -c "import jwt,sys,jsonc=jwt.decode(sys.argv[1],options={'verify_signature':False})bad=[k for k in c if k.lower() in {'email','phone','name','address','ssn'}]print('PII trong payload:',bad) if bad else print('ok')sys.exit(1 if bad else 0)" "$T"public class JwtValidationTests : IClassFixture<ApiFixture>{ private readonly ApiFixture _fx; public JwtValidationTests(ApiFixture fx) => _fx = fx; /// <summary> /// alg: none. Đóng bởi RequireSignedTokens. /// /// Test này và test algorithm confusion bên dưới phải RIÊNG: hai cấu hình khác /// nhau đóng chúng, nên gộp lại thì bỏ một trong hai vẫn có một test xanh. /// </summary> [Fact] public async Task Unsigned_token_is_rejected() { var token = TestJwt.Unsigned(new { sub = "1042", role = "Admin" }); var res = await _fx.Client.GetWithBearerAsync("/api/me", token); Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode); } /// <summary> /// ALGORITHM CONFUSION — test quan trọng nhất của bộ này, và là lỗ hổng phản /// trực giác nhất của topic. /// /// Server cấu hình RS256. Test ký token bằng HS256 với chính PUBLIC KEY của /// server làm secret HMAC — public key là công khai, nó ở /.well-known/jwks.json. /// Nếu thư viện chọn thuật toán theo alg của token thì nó gọi /// HMAC-verify(token, publicKey) và phép đó KHỚP. /// /// Đóng bởi ValidAlgorithms, KHÔNG bởi RequireSignedTokens: token này CÓ chữ ký. /// Bỏ dòng ValidAlgorithms ra thì đúng test này đỏ và test trên vẫn xanh. /// </summary> [Fact] public async Task Hs256_signed_with_the_rsa_public_key_is_rejected() { var publicKeyPem = _fx.RsaPublicKeyPem; var token = TestJwt.SignHs256( new { sub = "1042", role = "Admin", aud = "seclab-api", iss = _fx.Issuer }, secret: Encoding.UTF8.GetBytes(publicKeyPem)); var res = await _fx.Client.GetWithBearerAsync("/api/me", token); Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode); } /// <summary> /// aud — phép kiểm bị bỏ nhiều nhất, và nó cần một token có chữ ký HỢP LỆ. /// /// Đây là điểm: token này do ĐÚNG IdP của ta ký, bằng ĐÚNG khoá, và chữ ký của /// nó hoàn toàn đúng. Chỉ có aud là của một app khác. Một bản vá chỉ kiểm chữ ký /// sẽ nhận nó — và "role: Admin" có nghĩa trong app của ta. /// </summary> [Fact] public async Task Token_for_another_audience_is_rejected() { var token = _fx.IssueRealToken(aud: "internal-admin-tool", role: "Admin"); var res = await _fx.Client.GetWithBearerAsync("/api/me", token); Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode); } [Fact] public async Task Token_from_another_issuer_is_rejected() { var token = TestJwt.SignRs256( new { sub = "1042", aud = "seclab-api", iss = "https://evil-idp.example" }, _fx.OtherIdpPrivateKey); Assert.Equal(HttpStatusCode.Unauthorized, (await _fx.Client.GetWithBearerAsync("/api/me", token)).StatusCode); } /// <summary> /// jku — header trỏ tới JWKS của kẻ tấn công. Chữ ký khớp key của HỌ, nên nếu /// thư viện đi lấy key từ đó thì nó verify thành công. /// </summary> [Fact] public async Task Jku_header_is_ignored() { var (token, _) = TestJwt.SignWithAttackerKeyAndJku( new { sub = "1042", role = "Admin", aud = "seclab-api", iss = _fx.Issuer }, jku: "https://evil.example/jwks.json"); Assert.Equal(HttpStatusCode.Unauthorized, (await _fx.Client.GetWithBearerAsync("/api/me", token)).StatusCode); } /// <summary> /// TTL. JWT không thu hồi được, nên TTL LÀ cửa sổ rủi ro — không phải một con số /// tiện lợi. Và ClockSkew cộng vào nó, nên test kiểm cả hai. /// </summary> [Fact] public async Task Access_token_ttl_is_short() { var token = _fx.IssueRealToken(); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); var ttl = jwt.ValidTo - jwt.ValidFrom; Assert.True(ttl <= TimeSpan.FromMinutes(15), $"TTL {ttl.TotalMinutes:F0} phút là quá dài"); } /// <summary> /// Payload là CÔNG KHAI. Test này bắt được lỗi mà không ai coi là lỗ hổng cho tới /// khi một token xuất hiện trong log của một CDN. /// </summary> [Fact] public void Payload_contains_no_pii() { var jwt = new JwtSecurityTokenHandler().ReadJwtToken(_fx.IssueRealToken()); string[] forbidden = ["email", "phone", "name", "given_name", "address", "birthdate"]; var leaked = jwt.Claims.Select(c => c.Type) .Where(t => forbidden.Contains(t, StringComparer.OrdinalIgnoreCase)) .ToList(); Assert.Empty(leaked); } /// <summary>Cặp đôi: token thật vẫn phải dùng được.</summary> [Fact] public async Task Valid_token_is_accepted() { var res = await _fx.Client.GetWithBearerAsync("/api/me", _fx.IssueRealToken()); res.EnsureSuccessStatusCode(); }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
Đọc alg từ token rồi chọn thuật toán theo đó | Chính là lỗ hổng. Thuật toán phải đến từ cấu hình server |
Chặn riêng alg: none | Đóng một đường, để nguyên algorithm confusion. ValidAlgorithms đóng cả hai |
| Chỉ kiểm chữ ký | Token của app khác trong cùng IdP có chữ ký HỢP LỆ. Cần aud và iss |
Tải key từ jku/x5u trong header | Kẻ tấn công trỏ nó tới server của họ. Key phải từ endpoint đã cấu hình |
| Giải mã payload ở client để phân quyền | Payload chưa verify là dữ liệu của kẻ tấn công. Phân quyền ở server |
| Đặt PII trong payload | base64url không phải mã hoá. Mọi claim công khai với ai có token |
| Access token TTL 24 giờ | JWT không thu hồi được, nên đó là 24 giờ một tài khoản đã khoá vẫn dùng được |
Refresh token trong localStorage | XSS đọc được. Cookie HttpOnly thì không — xem topic xss |
Giữ ClockSkew mặc định | 5 phút của Microsoft cộng thẳng vào TTL thật của mọi token |
| Tự viết hàm verify JWT | CVE-2022-21449 cho thấy cả JDK cũng sai chỗ này. Dùng thư viện, và biết phiên bản |
Sai lầm về kiến trúc, và nó lớn hơn mọi hàng trên: chọn JWT rồi mới phát hiện cần thu hồi ngay. Lúc đó người ta thêm một denylist Redis vào đường verify — và bỏ đi chính lý do dùng JWT. Câu hỏi phải trả lời trước: bạn có cần verify không state ở nhiều service không? Nếu không, một session id opaque nhỏ hơn, đơn giản hơn, và thu hồi được.
Sai lầm về niềm tin: nghĩ JWT là một cơ chế bảo mật. Nó là một định dạng. Bảo mật đến từ bảy phép kiểm ở khối 3 — và bốn trong bảy hay bị bỏ.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…