Skip to content

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: ClockSkew mặ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 TokenValidationParameters an 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 <- Signature

Ba 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.

json
{
  "sub": "8f14e45f-ceea-467a-9f36-6c2e0b1b6d3a",
  "email": "[email protected]",
  "role": "SalesRep",
  "iss": "crm-api",
  "aud": "crm-client",
  "exp": 1719800000,
  "iat": 1719799100,
  "jti": "b2c3d4e5-..."
}
ClaimNghĩaBắt buộc?
subChủ thể — id người dùngCó
issAi phát hành tokenCó
audToken dành cho ai dùngCó
expHết hạn lúc nào (Unix seconds)Có
iatPhát hành lúc nàoNên có
jtiId duy nhất của tokenNê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á":

csharp
// 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 ​

csharp
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ì
ValidateIssuerSigningKeyChấp nhận token ký bằng khoá bất kỳ — lỗ hổng nghiêm trọng nhất
ValidateLifetimeToken không bao giờ hết hạn
ValidateIssuerToken của hệ thống khác dùng được ở đây
ValidateAudienceToken cho service khác dùng được ở đây
RequireSignedTokensToken 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ố.

csharp
ClockSkew = TimeSpan.Zero;               // chính xác tuyệt đối
ClockSkew = TimeSpan.FromSeconds(30);    // đủ cho NTP lệch bình thường

Zero 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 ​

csharp
// 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 chungCặp riêng/công
Ai xác thực đượcAi có khoá bí mậtAi có khoá công
Ai phát hành đượcAi có khoá bí mậtChỉ người có khoá riêng
Tốc độNhanh hơnChậm hơn
Hợp vớiMột service tự phát và tự xác thựcNhiề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:

bash
openssl rand -base64 48

Và 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 ​

csharp
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 ​

csharp
[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.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.

File gốc: nền sáng · nền tối

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ốnVới sessionVới JWT
Đăng xuất ngay lập tứcXoá sessionKhông làm được
Khoá tài khoản ngayXoá sessionKhông làm được
Thu hồi quyền ngayCập nhật sessionKhông làm được
Scale ngang không cần kho chungCần RedisLàm được

Ba cách sống chung:

  1. 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.
  2. Blacklist theo jti trong 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.
  3. 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:

bash
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:

bash
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:

json
{
  "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ườngVấn đề
salary_bandDữ liệu nhân sự nhạy cảm, lộ cho bất kỳ ai xem được token
internal_noteGhi 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:

  1. Người dùng đọc được nó có sao không? Không sao thì mới được đặt.
  2. Nó có cần cho mọi request không? Không thì để ở endpoint riêng, đừng mang theo mãi.
  3. 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:

csharp
_logger.LogDebug("Gọi API với token {Token}", token);   // SAI

Ngườ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:

csharp
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);
bash
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
# 200

Vẫn 200 sau khi token đã hết hạn 2 phút.

Với ClockSkew = TimeSpan.Zero:

csharp
options.TokenValidationParameters = new TokenValidationParameters
{
    // ...
    ClockSkew = TimeSpan.Zero
};
text
401
http
WWW-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ìnhexp đặtThật sự dùng đượcChênh
Mặc định15 phút20 phút+33%
FromSeconds(30)15 phút15,5 phút+3%
Zero15 phút15 phút0%

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ứ:

bash
timedatectl set-ntp true
timedatectl status | grep synchronized     # xác nhận: yes

và 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:

csharp
[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ý:

csharp
options.TokenValidationParameters = new TokenValidationParameters
{
    ValidateIssuerSigningKey = false,      // <- tắt
    ValidateIssuer = true,
    ValidateAudience = true,
    ValidateLifetime = true,
    IssuerSigningKey = key
};
text
Assert.Equal() Failure
Expected: Unauthorized
Actual:   OK

Test đỏ — 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 issuer sai,
  • hoặc audience sai,
  • hoặc token hết hạn,
  • hoặc endpoint đó vốn đã 401 cho 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:

bash
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")
PY

Token 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
ValidateIssuerSigningKeyAi cũng tự phát token
ValidateIssuerToken của hệ thống khác dùng được ở đây
ValidateAudienceToken cấp cho dịch vụ A dùng được ở dịch vụ B
ValidateLifetimeToken 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:

  1. JWT được ký, không mã hoá. Payload là công khai với bất kỳ ai cầm token.
  2. ClockSkew mặc định 5 phút kéo dài thời gian sống thật của token thêm 5 phút.
  3. 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 ​

Điều hướng ​

Phát hành theo giấy phép MIT.