10.2 - JWT Authentication
Tóm tắt: Điều quan trọng nhất về JWT, và cũng bị hiểu sai nhiều nhất: JWT được ký, không được mã hoá. Payload chỉ là Base64Url — bất kỳ ai cầm token đều đọc được toàn bộ nội dung, không cần khoá. Chữ ký chỉ đảm bảo token không bị sửa. Nên đừng bao giờ đặt thông tin nhạy cảm vào payload. Hai quyết định cấu hình quan trọng:
ClockSkewmặc định là 5 phút, nghĩa là token đã hết hạn vẫn được chấp nhận thêm 5 phút nữa; và khoá ký phải đủ 256 bit cho HMAC-SHA256, nếu không thư viện từ chối. Và giới hạn cốt lõi của JWT: nó không thu hồi được — token đã phát ra sẽ hợp lệ tới khi hết hạn, bất kể bạn làm gì.
Mục tiêu bài học
Sau bài này bạn có thể:
- Giải thích ba phần của JWT và ý nghĩa từng claim chuẩn.
- Cấu hình
TokenValidationParametersan toàn. - Chọn giữa HMAC và RSA để ký token.
- Giải thích vì sao JWT không thu hồi được và cách sống chung với điều đó.
- Tránh những lỗi cấu hình phổ biến nhất.
Nội dung bài học
10.2.1 — Cấu trúc và những gì nó không làm
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 <- Header
.eyJzdWIiOiIxMjMiLCJyb2xlIjoiU2FsZXNSZXAifQ <- Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c <- SignatureBa phần, hai phần đầu là Base64Url của JSON — không mã hoá, chỉ mã hoá ký tự. Dán bất kỳ token nào vào một trang giải mã Base64 và bạn đọc được toàn bộ payload.
{
"sub": "8f14e45f-ceea-467a-9f36-6c2e0b1b6d3a",
"email": "[email protected]",
"role": "SalesRep",
"iss": "crm-api",
"aud": "crm-client",
"exp": 1719800000,
"iat": 1719799100,
"jti": "b2c3d4e5-..."
}| Claim | Nghĩa | Bắt buộc? |
|---|---|---|
sub | Chủ thể — id người dùng | Có |
iss | Ai phát hành token | Có |
aud | Token dành cho ai dùng | Có |
exp | Hết hạn lúc nào (Unix seconds) | Có |
iat | Phát hành lúc nào | Nên có |
jti | Id duy nhất của token | Nên có — để blacklist |
aud quan trọng hơn vẻ ngoài: nó ngăn token phát cho service A được dùng ở service B. Không kiểm tra aud, một token hợp lệ cho ứng dụng nội bộ có thể dùng được trên API công khai.
Hệ quả của "chỉ ký, không mã hoá":
// SAI — ai cầm token cũng đọc được
claims.Add(new Claim("salary", "50000000"));
claims.Add(new Claim("ssn", "001234567890"));
claims.Add(new Claim("internal_notes", "..."));Cần bí mật thật thì dùng JWE (JSON Web Encryption), hoặc đơn giản hơn: đừng đặt nó vào token.
10.2.2 — Cấu hình xác thực
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
var jwt = builder.Configuration.GetSection("Jwt").Get<JwtOptions>()!;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt.Issuer,
ValidateAudience = true,
ValidAudience = jwt.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwt.SecretKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
ValidAlgorithms = [SecurityAlgorithms.HmacSha256], // chot thuat toan
RequireExpirationTime = true,
RequireSignedTokens = true,
};
options.MapInboundClaims = false; // giu nguyen ten claim trong token
});Năm thứ không bao giờ được tắt:
| Tham số | Tắt đi thì |
|---|---|
ValidateIssuerSigningKey | Chấp nhận token ký bằng khoá bất kỳ — lỗ hổng nghiêm trọng nhất |
ValidateLifetime | Token không bao giờ hết hạn |
ValidateIssuer | Token của hệ thống khác dùng được ở đây |
ValidateAudience | Token cho service khác dùng được ở đây |
RequireSignedTokens | Token alg: none được chấp nhận |
Người ta hay tắt chúng "tạm thời để debug" rồi quên bật lại. Một bài test integration kiểm tra rằng token ký bằng khoá sai bị từ chối sẽ chặn được chuyện đó.
ValidAlgorithms là tầng phòng thủ đáng thêm: nó chốt cứng thuật toán, ngăn thuộc tính alg trong header token ảnh hưởng tới cách xác thực. Các phiên bản thư viện hiện đại đã xử lý chuyện này, nhưng khai rõ vẫn tốt hơn.
10.2.3 — ClockSkew: mặc định 5 phút
Mặc định của TokenValidationParameters là ClockSkew = 5 phút — token đã hết hạn vẫn được chấp nhận thêm 5 phút nữa, để bù cho đồng hồ lệch giữa các máy.
Với access token 15 phút, nghĩa là thời gian sống thật là 20 phút — tăng 33%. Đó là điều bạn phải biết khi thiết kế, không phải phát hiện lúc điều tra sự cố.
ClockSkew = TimeSpan.Zero; // chính xác tuyệt đối
ClockSkew = TimeSpan.FromSeconds(30); // đủ cho NTP lệch bình thườngZero nghe an toàn nhất nhưng gây lỗi giả khi đồng hồ server lệch vài giây — và trong môi trường container, lệch vài giây là bình thường. 30 giây là con số cân bằng.
Nếu bạn dùng Zero, hãy chắc chắn mọi máy đều đồng bộ NTP.
10.2.4 — HMAC hay RSA
// HMAC (HS256) — MỘT khoá bí mật dùng chung
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
// RSA (RS256) — khoá RIÊNG để ký, khoá CÔNG để xác thực
var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
var creds = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256);| HMAC (HS256) | RSA (RS256) | |
|---|---|---|
| Khoá | Một, dùng chung | Cặp riêng/công |
| Ai xác thực được | Ai có khoá bí mật | Ai có khoá công |
| Ai phát hành được | Ai có khoá bí mật | Chỉ người có khoá riêng |
| Tốc độ | Nhanh hơn | Chậm hơn |
| Hợp với | Một service tự phát và tự xác thực | Nhiều service, identity provider riêng |
Điểm quyết định nằm ở dòng thứ ba. Với HMAC, mọi service xác thực được token cũng phát hành được token. Chia sẻ khoá HMAC cho 5 microservice nghĩa là bất kỳ service nào bị xâm nhập cũng có thể tự phát token admin.
Với RSA, service chỉ cần khoá công để xác thực — nó không thể giả mạo token.
Quy tắc: một ứng dụng tự phát và tự xác thực thì HMAC đủ. Từ hai service trở lên thì RSA. Và khi dùng identity provider (Azure AD, Auth0, Keycloak) thì bạn luôn dùng RSA với khoá công lấy từ endpoint JWKS.
Khoá HMAC phải đủ 256 bit (32 ký tự ASCII) cho HS256 — thư viện từ chối khoá ngắn hơn. Sinh khoá ngẫu nhiên, đừng gõ tay:
openssl rand -base64 48Và khoá đó là secret — nó thuộc về biến môi trường hoặc Key Vault, không phải appsettings.json (bài 8.6).
10.2.5 — Phát hành token
public sealed class TokenService(IOptions<JwtOptions> options, TimeProvider clock) : ITokenService
{
private readonly JwtOptions _jwt = options.Value;
public string CreateAccessToken(AppUser user, IEnumerable<string> roles)
{
var now = clock.GetUtcNow();
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Email, user.Email),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(JwtRegisteredClaimNames.Iat,
now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
};
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: claims,
notBefore: now.UtcDateTime,
expires: now.AddMinutes(_jwt.AccessTokenMinutes).UtcDateTime,
signingCredentials: new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SecretKey)),
SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
}Tiêm TimeProvider thay vì gọi DateTime.UtcNow để test được hành vi hết hạn — bài 7.1.
jti cho phép blacklist một token cụ thể; iat giúp phát hiện token quá cũ ngay cả khi exp còn hạn.
10.2.6 — Endpoint đăng nhập
[HttpPost("login")]
[AllowAnonymous]
[EnableRateLimiting("login")] // BAT BUOC — chong do mat khau
public async Task<ActionResult<TokenResponse>> Login(
LoginRequest request, CancellationToken ct)
{
var user = await _userManager.FindByEmailAsync(request.Email);
// Thông báo GIỐNG NHAU cho mọi trường hợp thất bại
if (user is null || !await _userManager.CheckPasswordAsync(user, request.Password))
{
_logger.LogWarning("Đăng nhập thất bại cho {Email} từ {Ip}",
request.Email, HttpContext.Connection.RemoteIpAddress);
return Unauthorized(new ProblemDetails
{
Title = "Đăng nhập thất bại",
Detail = "Email hoặc mật khẩu không đúng."
});
}
var roles = await _userManager.GetRolesAsync(user);
var accessToken = _tokenService.CreateAccessToken(user, roles);
var refreshToken = await _refreshTokens.IssueAsync(user.Id, ct);
return Ok(new TokenResponse(accessToken, refreshToken, _jwt.AccessTokenMinutes * 60));
}Ba điểm bảo mật trong đoạn ngắn này:
1. Thông báo lỗi giống nhau. "Email không tồn tại" và "Mật khẩu sai" cho kẻ tấn công biết email nào có trong hệ thống — đó là bước đầu của tấn công dò tài khoản.
2. Rate limit là bắt buộc. Không có nó, endpoint đăng nhập là cửa mở cho dò mật khẩu tự động (bài 9.6). UserManager.AccessFailedAsync với lockout là tầng thứ hai.
3. Log mọi lần thất bại kèm IP. Đây là nguồn dữ liệu duy nhất để phát hiện tấn công đang diễn ra.
Một điểm tinh tế: FindByEmailAsync trả null nhanh hơn CheckPasswordAsync chạy xong, nên thời gian phản hồi tiết lộ email có tồn tại hay không. Với hệ thống cần chống điều này, hãy băm một mật khẩu giả khi không tìm thấy user.
Ghép cấu hình ở mục 10.2.2 với endpoint đăng nhập ở trên, cả vòng đời của access token chỉ xoay quanh một khoá:
![Sơ đồ phát hành và xác thực JWT: client gọi endpoint đăng nhập ([AllowAnonymous], có rate limit), endpoint chuyển claims cho TokenService ký token HS256 chứa sub, jti, iat, role, rồi trả access token về client. Ở mỗi request sau, client gửi Bearer token tới JwtBearer handler; handler dùng cùng khoá ký bí mật (SymmetricSecurityKey, để trong biến môi trường hoặc Key Vault) kiểm chữ ký, alg, iss, aud và exp với ClockSkew 30 giây. Hợp lệ thì action có [Authorize] chạy với HttpContext.User; sai hoặc hết hạn thì trả 401 Unauthorized. Server không lưu token nên token hợp lệ tới khi hết exp và không thu hồi được.](/assets/jwt-issue-validate.Dbl1mjB9.png)
![Sơ đồ phát hành và xác thực JWT: client gọi endpoint đăng nhập ([AllowAnonymous], có rate limit), endpoint chuyển claims cho TokenService ký token HS256 chứa sub, jti, iat, role, rồi trả access token về client. Ở mỗi request sau, client gửi Bearer token tới JwtBearer handler; handler dùng cùng khoá ký bí mật (SymmetricSecurityKey, để trong biến môi trường hoặc Key Vault) kiểm chữ ký, alg, iss, aud và exp với ClockSkew 30 giây. Hợp lệ thì action có [Authorize] chạy với HttpContext.User; sai hoặc hết hạn thì trả 401 Unauthorized. Server không lưu token nên token hợp lệ tới khi hết exp và không thu hồi được.](/assets/jwt-issue-validate-dark.bqLQYThF.png)
10.2.7 — JWT không thu hồi được
Đây là đánh đổi cốt lõi của JWT, và phải hiểu rõ trước khi chọn nó.
Server không lưu gì về token. Nó chỉ xác thực chữ ký và hạn. Hệ quả: token đã phát ra hợp lệ cho tới khi hết hạn, kể cả khi người dùng đã đăng xuất, bị khoá tài khoản, hay bị thu hồi quyền.
| Muốn | Với session | Với JWT |
|---|---|---|
| Đăng xuất ngay lập tức | Xoá session | Không làm được |
| Khoá tài khoản ngay | Xoá session | Không làm được |
| Thu hồi quyền ngay | Cập nhật session | Không làm được |
| Scale ngang không cần kho chung | Cần Redis | Làm được |
Ba cách sống chung:
- TTL ngắn — 5 đến 15 phút. Đơn giản nhất và đủ cho phần lớn hệ thống: cửa sổ rủi ro là 15 phút.
- Blacklist theo
jtitrong Redis với TTL bằng hạn còn lại của token. Nó phá tính stateless, nhưng kho này nhỏ và tra cứu nhanh. - Cột
SecurityStamp— mỗi lần đổi mật khẩu hay thu hồi quyền thì đổi stamp, và token mang stamp cũ bị từ chối. Cần tra database mỗi request, nên thường dùng kèm cache ngắn.
Nếu bạn cần đăng xuất ngay lập tức và hệ thống chỉ có một service, session truyền thống có thể là lựa chọn đúng hơn JWT. JWT không phải luôn tốt hơn — nó đánh đổi khả năng thu hồi lấy khả năng scale.
10.2.8 — Rà lại code của bạn
Danh sách rà soát JWT
- Không có thông tin nhạy cảm nào trong payload — nó đọc được công khai.
- ValidateIssuerSigningKey, ValidateLifetime, ValidateIssuer, ValidateAudience đều bật.
- ValidAlgorithms được khai rõ, không để thuật toán do token quyết định.
- ClockSkew được đặt có chủ đích, biết rõ mặc định là 5 phút.
- Khoá ký đủ 256 bit, sinh ngẫu nhiên, nằm trong biến môi trường hoặc Key Vault.
- Nhiều service thì dùng RSA, không chia sẻ khoá HMAC.
- Access token có TTL ngắn, từ 5 đến 15 phút.
- Token có jti để blacklist được khi cần.
- Endpoint đăng nhập có rate limit và lockout.
- Thông báo lỗi đăng nhập giống nhau cho mọi nguyên nhân thất bại.
- Có test kiểm tra token ký bằng khoá sai bị từ chối.
Bài tập áp dụng
Bài 1 — Đọc payload của chính token mình phát
Lấy một token do hệ thống bạn phát, tách phần giữa và giải mã Base64Url. Liệt kê những gì lộ ra và đánh giá có gì không nên ở đó.
Tiêu chí hoàn thành: bạn giải mã được mà không cần khoá bí mật, và nêu được điều đó nói gì về những thứ được phép đặt trong token.
Gợi ý và lời giải — Bài 1
Gợi ý. JWT có ba phần ngăn bởi dấu chấm. Phần giữa chỉ được mã hoá Base64Url, không phải mã hoá bí mật. Hai việc đó khác nhau hoàn toàn.
Lời giải — giải mã bằng một dòng:
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .Base64Url bỏ dấu = ở cuối nên đôi khi cần đệm lại:
python3 -c "
import base64,json,sys
p = sys.argv[1].split('.')[1]
p += '=' * (-len(p) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(p)), indent=2, ensure_ascii=False))
" "$TOKEN"Kết quả điển hình:
{
"sub": "8f3a1b2c-0000-0000-0000-000000000001",
"email": "[email protected]",
"name": "Nguyễn Văn An",
"role": ["Manager", "SalesRep"],
"tenant_id": "acme",
"salary_band": "B3",
"internal_note": "đang trong diện xem xét kỷ luật",
"exp": 1790000000,
"iat": 1789999100,
"iss": "https://crm.company.com",
"aud": "crm-api"
}Không cần khoá bí mật nào. Đây là điểm mấu chốt: chữ ký bảo vệ tính toàn vẹn (không ai sửa được nội dung mà không bị phát hiện), chứ không bảo vệ tính bí mật. Bất kỳ ai cầm token — kể cả JavaScript trên trình duyệt, kể cả một log file, kể cả người ngồi cạnh nhìn màn hình — đều đọc được toàn bộ payload.
Hai trường trong ví dụ trên không nên có ở đó:
| Trường | Vấn đề |
|---|---|
salary_band | Dữ liệu nhân sự nhạy cảm, lộ cho bất kỳ ai xem được token |
internal_note | Ghi chú nội bộ — người dùng đọc được chính ghi chú về mình |
Quy tắc đặt claim, theo ba câu hỏi:
- Người dùng đọc được nó có sao không? Không sao thì mới được đặt.
- Nó có cần cho mọi request không? Không thì để ở endpoint riêng, đừng mang theo mãi.
- Nó có đổi trong vòng đời token không? Có thì đừng đặt — token không cập nhật được.
Điều này cũng giải thích vì sao đừng bao giờ ghi token vào log. Một dòng log tưởng vô hại:
_logger.LogDebug("Gọi API với token {Token}", token); // SAINgười đọc được log giờ đọc được mọi claim của mọi người dùng, và dùng lại token đó cho tới khi hết hạn. Cấu hình che header Authorization là bắt buộc, như bài 8.7 trình bày.
Nếu thật sự cần giấu nội dung, JWT có bản mã hoá là JWE — nhưng gần như không đáng dùng cho API nội bộ. Cách đơn giản hơn nhiều: chỉ đặt một sub trong token và tra mọi thứ khác từ database.
Bài 2 — ClockSkew mặc định kéo dài tuổi token thêm 5 phút
Phát một token hạn 1 phút, chờ 3 phút rồi gọi API. Xác nhận nó vẫn được chấp nhận. Đặt ClockSkew = TimeSpan.Zero và kiểm chứng lại.
Tiêu chí hoàn thành: bạn tính được tuổi thọ thật của access token trong hệ thống mình, và nêu được vì sao Zero không phải lựa chọn mặc định tốt.
Gợi ý và lời giải — Bài 2
Gợi ý. exp trong token không phải thời điểm token ngừng được chấp nhận. Thời điểm đó là exp + ClockSkew.
Lời giải — với cấu hình mặc định:
var token = new JwtSecurityToken(
issuer: "https://crm.company.com",
audience: "crm-api",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(1), // hạn 1 phút
signingCredentials: creds);TOKEN=$(curl -s -X POST .../auth/login -d '...' | jq -r .accessToken)
sleep 180 # chờ 3 phút
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
https://localhost:5001/api/v1/customers
# 200Vẫn 200 sau khi token đã hết hạn 2 phút.
Với ClockSkew = TimeSpan.Zero:
options.TokenValidationParameters = new TokenValidationParameters
{
// ...
ClockSkew = TimeSpan.Zero
};401WWW-Authenticate: Bearer error="invalid_token",
error_description="The token expired at '09/25/2026 08:31:00'"Tuổi thọ thật của token:
| Cấu hình | exp đặt | Thật sự dùng được | Chênh |
|---|---|---|---|
| Mặc định | 15 phút | 20 phút | +33% |
FromSeconds(30) | 15 phút | 15,5 phút | +3% |
Zero | 15 phút | 15 phút | 0% |
Con số này quan trọng khi bạn thiết kế thời hạn. Chọn 5 phút cho access token mà không biết về ClockSkew thì thật ra bạn đang chạy với 10 phút — gấp đôi ý định.
Vì sao Zero không phải mặc định tốt. Nó bắt đồng hồ của máy phát token và máy kiểm token khớp nhau tới từng giây. Trong container, lệch vài giây là chuyện bình thường, và hậu quả là lỗi 401 ngẫu nhiên cho token vừa được cấp — kiểu lỗi không tái hiện được, không có trong log ứng dụng, và rất tốn thời gian điều tra. Ca ở bài 2.11 là đúng tình huống này, chỉ khác chiều lệch.
30 giây là con số cân bằng: đủ bù cho lệch NTP bình thường, và chỉ kéo dài tuổi token thêm 3%.
Nếu bạn chọn Zero, phải kèm hai thứ:
timedatectl set-ntp true
timedatectl status | grep synchronized # xác nhận: yesvà một health check theo dõi đồng bộ thời gian, để lần sau hệ thống tự báo thay vì chờ người dùng phát hiện.
Một chi tiết ít người biết: ClockSkew áp cho cả nbf (not before), không chỉ exp. Token phát ra với nbf trong tương lai gần vẫn được chấp nhận sớm — điều này quan trọng nếu bạn dùng nbf để lên lịch hiệu lực.
Bài 3 — Bài test chứng minh chữ ký thật sự được kiểm
Viết integration test phát token bằng một khoá khác và xác nhận API trả 401. Sau đó tắt ValidateIssuerSigningKey và xác nhận test đỏ.
Tiêu chí hoàn thành: bạn giải thích được vì sao bước "tắt rồi xem test đỏ" là phần quan trọng nhất của bài.
Gợi ý và lời giải — Bài 3
Gợi ý. Một bài test luôn xanh không chứng minh được gì — kể cả khi nó xanh vì lý do sai.
Lời giải — bài test:
[Fact]
public async Task TokenKySaiKhoa_TraVe401()
{
var wrongKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("khoa-hoan-toan-khac-du-dai-32-byte!!"));
var token = new JwtSecurityToken(
issuer: "https://crm.company.com",
audience: "crm-api",
claims: [new Claim(ClaimTypes.NameIdentifier, "42")],
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: new SigningCredentials(
wrongKey, SecurityAlgorithms.HmacSha256));
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new("Bearer", new JwtSecurityTokenHandler().WriteToken(token));
var res = await client.GetAsync("/api/v1/customers");
Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);
}Chạy — xanh.
Bây giờ tắt kiểm tra chữ ký:
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = false, // <- tắt
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
IssuerSigningKey = key
};Assert.Equal() Failure
Expected: Unauthorized
Actual: OKTest đỏ — và đây chính là điều bài tập muốn chứng minh.
Vì sao bước này quan trọng nhất. Bài test đầu tiên xanh, nhưng bạn chưa biết nó xanh vì lý do gì. Nó có thể xanh vì:
- chữ ký bị từ chối — điều bạn muốn kiểm,
- hoặc
issuersai, - hoặc
audiencesai, - hoặc token hết hạn,
- hoặc endpoint đó vốn đã
401cho mọi thứ vì cấu hình sai chỗ khác.
Cả năm khả năng đều cho 401. Chỉ khi tắt đúng cái van bạn đang kiểm và thấy test chuyển sang đỏ, bạn mới biết test đang canh đúng thứ. Đây là ý tưởng của kiểm thử đột biến, áp dụng bằng tay cho một trường hợp.
Vì sao ValidateIssuerSigningKey = false là thảm hoạ. Không kiểm chữ ký nghĩa là bất kỳ ai cũng tự phát token cho chính mình:
python3 - <<'PY'
import base64, json
def b64(d): return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b'=').decode()
print(b64({"alg":"HS256","typ":"JWT"}) + "." +
b64({"sub":"1","role":"Admin","exp":9999999999,
"iss":"https://crm.company.com","aud":"crm-api"}) + ".khong-can-chu-ky")
PYToken này không có chữ ký hợp lệ nào, nhưng với ValidateIssuerSigningKey = false nó được chấp nhận với vai trò Admin.
Bốn van kiểm tra, nên có bài test riêng cho từng cái:
| Tham số | Tắt đi thì sao |
|---|---|
ValidateIssuerSigningKey | Ai cũng tự phát token |
ValidateIssuer | Token của hệ thống khác dùng được ở đây |
ValidateAudience | Token cấp cho dịch vụ A dùng được ở dịch vụ B |
ValidateLifetime | Token không bao giờ hết hạn |
Viết bốn bài test theo đúng khuôn trên. Chúng rẻ, chạy trong vài mili-giây, và chặn được loại lỗi mà không ai phát hiện khi review — vì cấu hình sai vẫn chạy đúng với mọi token hợp lệ.
Tự kiểm tra
JWT có mã hoá nội dung không?
Không, nó chỉ được ký. Payload là Base64Url của JSON nên bất kỳ ai cầm token đều đọc được toàn bộ nội dung mà không cần khoá. Chữ ký chỉ đảm bảo token không bị sửa. Vì vậy không bao giờ đặt thông tin nhạy cảm vào payload.
Những tham số xác thực nào không bao giờ được tắt?
ValidateIssuerSigningKey, vì tắt đi là chấp nhận token ký bằng khoá bất kỳ. ValidateLifetime, vì tắt là token không bao giờ hết hạn. ValidateIssuer và ValidateAudience, vì tắt là token của hệ thống hoặc service khác dùng được ở đây. Và RequireSignedTokens để không chấp nhận alg none.
ClockSkew mặc định là bao nhiêu và vì sao quan trọng?
Năm phút. Nghĩa là token đã hết hạn vẫn được chấp nhận thêm 5 phút nữa, nên access token 15 phút có thời gian sống thật là 20 phút. Đặt về 0 thì chính xác nhưng gây lỗi giả khi đồng hồ lệch vài giây, điều rất bình thường trong container; 30 giây là con số cân bằng.
Khi nào phải dùng RSA thay vì HMAC?
Khi có từ hai service trở lên. Với HMAC, mọi service xác thực được token cũng phát hành được token, nên chia sẻ khoá cho 5 microservice nghĩa là bất kỳ service nào bị xâm nhập cũng tự phát được token admin. Với RSA, service chỉ cần khoá công để xác thực và không thể giả mạo.
Vì sao thông báo lỗi đăng nhập phải giống nhau?
Vì phân biệt email không tồn tại với mật khẩu sai cho kẻ tấn công biết email nào có trong hệ thống, đó là bước đầu của tấn công dò tài khoản. Cần chú ý cả thời gian phản hồi, vì tìm không thấy user thường nhanh hơn kiểm tra mật khẩu.
Vì sao JWT không thu hồi được và làm sao sống chung?
Vì server không lưu gì về token, chỉ xác thực chữ ký và hạn, nên token đã phát ra hợp lệ tới khi hết hạn kể cả khi người dùng đã đăng xuất hay bị khoá. Ba cách sống chung: TTL ngắn từ 5 tới 15 phút, blacklist theo jti trong Redis, hoặc dùng SecurityStamp đổi mỗi lần thu hồi quyền.
Kết luận
Ba điều đáng nhớ nhất:
- JWT được ký, không mã hoá. Payload là công khai với bất kỳ ai cầm token.
ClockSkewmặc định 5 phút kéo dài thời gian sống thật của token thêm 5 phút.- JWT đánh đổi khả năng thu hồi lấy khả năng scale. TTL ngắn là cách sống chung đơn giản nhất.
Tham khảo
- Configure JWT bearer authentication in ASP.NET Core
- RFC 7519 — JSON Web Token (JWT)
- RFC 9700 — Best Current Practice for OAuth 2.0 Security
- TokenValidationParameters class
Điều hướng
- Bài trước: 10.1 — Authentication so với Authorization
- Bài tiếp theo: 10.3 — Refresh Token
- Về module: Trang mục lục