JWT vulnerabilities: algorithm confusion và những lỗi cấu hình biến token thành điểm yếu
JWT (JSON Web Token) được dùng gần như khắp nơi trong web authentication. Nhưng JWT chỉ an toàn khi được implement đúng — và có tám attack vector phổ biến mà attacker khai thác thường xuyên. Nguy hiểm nhất là **algorithm confusion**: attacker tự đổi header từ RS256 sang HS256, ký token bằng public key (vốn không bí mật), và server chấp nhận vì không enforce algorithm. Bài này đi qua từng lỗ hổng với vulnerable code thực tế và fix đúng cách trong ASP.NET Core. ---
Vấn đề
Trong một buổi audit gần đây, tôi tìm thấy authentication endpoint trả về JWT với lifetime 365 ngày. Không phải typo — một năm. Lý do developer giải thích: "để user không phải đăng nhập lại". Đây là lỗi về design, không phải implementation — và nó là một trong tám vấn đề phổ biến nhất với JWT mà tôi sẽ phân tích trong bài này.
Nhưng trước tiên, cần hiểu JWT là gì để biết tại sao nó có thể bị khai thác.
JWT structure: Base64 không phải encryption
JWT có ba phần, mỗi phần Base64Url-encoded và nối bằng dấu chấm:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 ← Header
.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3MTIwMDAwMDB9 ← Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
Decode Header: {"alg":"RS256","typ":"JWT"}
Decode Payload: {"sub":"user123","role":"user","iat":1712000000}
Quan trọng: Base64 là encoding, không phải encryption. Bất kỳ ai có token đều đọc được payload. Đừng bao giờ store sensitive data (password, credit card, full PII) trong JWT claims.
Chỉ Signature là cung cấp tính toàn vẹn — và đây là nơi hầu hết vulnerability xảy ra.
8 JWT vulnerabilities và cách fix
1. Signature verification bypass — CRITICAL
// ❌ NGUY HIỂM: decode không verify
var handler = new JwtSecurityTokenHandler();
var token = handler.ReadToken(tokenString) as JwtSecurityToken;
// Claims từ token CHƯA được verify — attacker có thể forge toàn bộ
var userId = token.Claims.First(c => c.Type == "sub").Value;
var role = token.Claims.First(c => c.Type == "role").Value; // Attacker đặt "admin"
Fix: luôn dùng ValidateToken, không dùng ReadToken để lấy claims:
// ✅ ĐÚNG: ValidateToken throw exception nếu signature sai
try
{
var principal = handler.ValidateToken(tokenString, _validationParams, out _);
var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}
catch (SecurityTokenException)
{
return Unauthorized();
}
2. Algorithm confusion (RS256 → HS256) — HIGH
Đây là attack tinh vi nhất và hay bị bỏ sót nhất.
Cơ chế tấn công:
Server dùng RS256 (asymmetric): ký bằng private key, verify bằng public key. Public key thường public — ai cũng có thể lấy từ JWKS endpoint.
Attacker làm:
- Lấy public key của server (từ
/jwks.jsonhoặc certificate) - Tạo token mới với header
{"alg":"HS256"}và payload giả (role: admin) - Ký token bằng public key — dùng làm HMAC secret
- Gửi token lên server
Nếu server code như này:
// ❌ NGUY HIỂM: chấp nhận cả RS256 lẫn HS256
var validAlgorithms = new[] { "RS256", "HS256" };
// Khi nhận token alg=HS256, server dùng public key để verify HMAC
// Public key đã bị lộ → attacker verify thành công
Attacker tự tạo được token hợp lệ với bất kỳ payload nào.
Fix — whitelist algorithm:
// ✅ ĐÚNG: chỉ cho phép RS256, không bao giờ HS256 trong production JWT
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = GetRsaPublicKey(), // RSA public key
ValidateIssuer = true,
ValidIssuer = "https://auth.company.com",
ValidateAudience = true,
ValidAudience = "my-api",
ValidateLifetime = true,
// CRITICAL: Chỉ cho phép RS256
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
// Enforce thêm qua AlgorithmValidator
AlgorithmValidator = (algorithm, securityKey, token, parameters) =>
{
if (!algorithm.Equals("RS256", StringComparison.OrdinalIgnoreCase))
throw new SecurityTokenInvalidSignatureException(
$"Algorithm '{algorithm}' is not allowed.");
return true;
}
};
});
3. "none" algorithm — CRITICAL (legacy)
Một số JWT library cũ chấp nhận alg: none — nghĩa là không có signature. Attacker chỉ cần xóa signature và đổi header:
{"alg":"none","typ":"JWT"}.{"sub":"admin","role":"superadmin"}.
Không có signature cần verify — server chấp nhận token giả.
Modern System.IdentityModel.Tokens.Jwt từ version 5+ reject "none" by default, nhưng legacy apps cần verify:
// Verify rằng ValidAlgorithms không include "none"
var validationParams = new TokenValidationParameters
{
ValidAlgorithms = new[] { "RS256" }, // "none" không có trong list → bị reject
RequireSignedTokens = true // Thêm lớp bảo vệ: token phải có signature
};
4. Weak HMAC secret — HIGH
Nếu dùng HS256 (symmetric), secret key cần đủ entropy:
// ❌ NGUY HIỂM: key quá ngắn, dễ brute-force offline
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("mysecret")); // 8 bytes
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("12345678")); // Vô nghĩa
// ✅ ĐÚNG: minimum 256 bits (32 bytes) cho HS256
var keyBytes = new byte[64]; // 512 bits
RandomNumberGenerator.Fill(keyBytes);
var key = new SymmetricSecurityKey(keyBytes);
// Hoặc đọc từ environment/secret vault, không hardcode
var secret = Environment.GetEnvironmentVariable("JWT_SECRET_KEY")
?? throw new InvalidOperationException("JWT_SECRET_KEY not configured");
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
Secret key cần ≥ 256 bits và được generate bằng cryptographically secure RNG. Không dùng tên project, domain, hoặc bất kỳ predictable string nào.
5. Improper token storage — HIGH
// ❌ NGUY HIỂM: localStorage = XSS target
// Bất kỳ script nào trên trang đều đọc được
localStorage.setItem('accessToken', jwt);
// ❌ sessionStorage cũng không tốt hơn nhiều
sessionStorage.setItem('accessToken', jwt);
Nếu có XSS — dù chỉ là third-party script inject vào trang — attacker steal được toàn bộ token.
// ✅ ĐÚNG: HttpOnly cookie trong ASP.NET Core
// Server set cookie, JavaScript không đọc được
Response.Cookies.Append("access_token", jwt, new CookieOptions
{
HttpOnly = true, // Chặn JavaScript access
Secure = true, // Chỉ HTTPS
SameSite = SameSiteMode.Strict, // CSRF protection
Expires = DateTimeOffset.UtcNow.AddMinutes(15),
Path = "/"
});
Tradeoff: HttpOnly cookie cần xử lý CSRF (dùng SameSite=Strict + CSRF token cho state-changing operations). Nhưng đây vẫn tốt hơn localStorage với XSS risk.
6. Missing claims validation — MEDIUM
// ❌ KHÔNG ĐỦ: chỉ validate signature, không validate claims
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey
// ValidateIssuer = false ← Bất kỳ auth server nào cũng được chấp nhận
// ValidateAudience = false ← Token từ service khác cũng dùng được
};
// ✅ ĐÚNG: validate đầy đủ
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
ValidateIssuer = true,
ValidIssuer = "https://auth.company.com", // Chính xác issuer
ValidateAudience = true,
ValidAudience = "my-api-service", // Chỉ token cho service này
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30) // 30 giây tolerance, không phải 5 phút
};
ValidAudience đặc biệt quan trọng trong microservices: nếu bỏ qua, token từ service A dùng được trên service B, C, D — lateral movement trở nên dễ dàng hơn cho attacker.
7. Long token expiry — MEDIUM
// ❌ NGUY HIỂM: token sống 1 năm
var token = new JwtSecurityToken(
expires: DateTime.UtcNow.AddDays(365) // Nếu token bị steal, 1 năm attacker có quyền truy cập
);
// ✅ ĐÚNG: short-lived access token + refresh token pattern
// Access token: 15 phút
var accessToken = new JwtSecurityToken(
expires: DateTime.UtcNow.AddMinutes(15)
);
// Refresh token: lưu server-side, có thể revoke
// Không là JWT — là opaque token với lookup trong database
var refreshToken = GenerateOpaqueRefreshToken(); // Guid hoặc random bytes
await StoreRefreshToken(userId, refreshToken, expiresAt: DateTime.UtcNow.AddDays(30));
Access token ngắn hạn giới hạn window nếu bị compromise. Refresh token cho phép revoke ngay — điều JWT không làm được natively vì stateless.
8. Sensitive data trong payload — MEDIUM
// ❌ NGUY HIỂM: data nhạy cảm trong claims (Base64 = readable)
var claims = new[]
{
new Claim("sub", userId),
new Claim("email", userEmail),
new Claim("password_hash", hashedPassword), // Không cần thiết, nguy hiểm
new Claim("credit_card", maskedCard), // Không bao giờ
new Claim("ssn", socialSecurityNumber) // Tuyệt đối không
};
// ✅ ĐÚNG: chỉ store identifier, fetch sensitive data từ server
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim("role", userRole),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) // JWT ID để blacklist nếu cần
};
Checklist validation đầy đủ — copy-paste ready
// appsettings.json
// {
// "Jwt": {
// "Issuer": "https://auth.company.com",
// "Audience": "my-api",
// "PublicKeyPath": "/run/secrets/jwt-public-key.pem"
// }
// }
// Program.cs — JWT configuration chuẩn
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
var jwtConfig = configuration.GetSection("Jwt");
var rsaKey = RSA.Create();
rsaKey.ImportFromPem(File.ReadAllText(jwtConfig["PublicKeyPath"]));
options.TokenValidationParameters = new TokenValidationParameters
{
// Signature
ValidateIssuerSigningKey = true,
IssuerSigningKey = new RsaSecurityKey(rsaKey),
RequireSignedTokens = true,
// Claims
ValidateIssuer = true,
ValidIssuer = jwtConfig["Issuer"],
ValidateAudience = true,
ValidAudience = jwtConfig["Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
// Algorithm whitelist — reject "none" và HS256
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 }
};
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
// Log chi tiết, không expose ra response
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
logger.LogWarning("JWT authentication failed: {Error}",
context.Exception.GetType().Name);
return Task.CompletedTask;
}
};
});
Kết
JWT an toàn khi dùng đúng — nhưng "đúng" có nghĩa là enforce algorithm, validate đầy đủ claims, lưu token trong HttpOnly cookie, và giữ access token ngắn hạn. Hầu hết vulnerability không đến từ thư viện JWT bị bug, mà từ cấu hình thiếu sót hoặc hiểu nhầm JWT là encryption trong khi thực ra nó chỉ là signed encoding.
Bài tiếp theo trong series: Supply chain attack — 454.000 malicious packages npm trong năm 2025, cách attacker nhắm vào dependencies của bạn, và checklist bảo vệ dự án .NET.
Thiết Kiếm — BKGlobal Tech Team