What it is
A JWT is a self-describing token: it carries both the data (claims) and how it was signed (the alg header). That is the problem — a token that states how to verify itself can also lie about it, and most JWT vulnerabilities are a library trusting the token's header instead of your configuration.
Why you should care
The thing to grasp: JWT is not wrong, how JWT gets used usually is — and the two most common flaws are not cryptographic, they are omissions.
- Not validating
alg. The library readsalgfrom the token and picks the algorithm accordingly.alg: none(accepting an unsigned token) and algorithm confusion (feeding an RSA public key into an HMAC verify, so the public key becomes the secret) both come from this. - Not validating
audandiss. A token signed by your real identity provider, for a DIFFERENT application, still verifies if you only check the signature. Across services sharing one IdP, that is lateral privilege escalation.
And one architectural point that matters more than either: JWTs cannot be revoked. That is their entire value (verification needs no database call) and their entire cost. So a 24-hour access token means a disabled account keeps working for 24 hours. If you need immediate revocation you need a session store — and at that point JWT buys you nothing except a larger payload.
Plus one common misconception: a JWT is not encrypted. eyJ... is base64url, not ciphertext. Every claim inside is public to anyone holding the token.
How the attack works
The structure is three base64url parts joined by dots: header.payload.signature. The vulnerability lives in who decides the verification algorithm.
flowchart TD T["Token: header.payload.signature<br/>header = #quot;alg#quot;: #quot;RS256#quot;"] --> Q{Where does the verify<br/>algorithm come from?} Q -->|"From the TOKEN header"| B1["alg: none → unsigned accepted"] Q -->|"From the TOKEN header"| B2["alg: HS256 + the RSA public key<br/>as the HMAC secret"] B1 --> X["🔓 Forge any claim"] B2 --> X Q -->|"From the SERVER config"| G["RS256 only, this key only"] G --> V{Then check aud, iss, exp} V -->|"all present"| OK["✅ Accept"] V -->|"aud/iss missing"| L["⚠ Another app's token passes"]Algorithm confusion deserves a careful explanation because it is the most counter-intuitive flaw here: the server is configured for RS256, the attacker changes the header to HS256 and signs the token with the server's own public key (which is public — it sits at /.well-known/jwks.json). If the library picks the algorithm from the token's alg, it calls HMAC-verify(token, publicKey) — and that matches, because the attacker used that exact string as the secret.
Seven checks, four of them commonly skipped:
| Check | Consequence of skipping |
|---|---|
| Signature | Forge any claim |
alg against a server allowlist | alg: none, algorithm confusion |
exp | Tokens live forever |
aud | Another app's token from the same IdP passes |
iss | A token signed by a different IdP passes |
kid must be in the trusted JWKS | kid pointing at an attacker URL (jku/x5u) |
nbf | Tokens usable before their validity window |
kid deserves its own note: some libraries read jku (JWK Set URL) from the header and fetch the key from it. The attacker points jku at their own server, and the signature matches their key.
Diagram description: A decision diagram for verifying a JWT. The central question is where the verification algorithm comes from. If it comes from the token header there are two attack paths: alg none makes the server accept an unsigned token, and alg HS256 with the RSA public key used as the HMAC secret — both allow forging arbitrary claims. If it comes from the server configuration the token proceeds to the aud, iss and exp checks; with all present it is accepted, but with aud or iss missing another application's token from the same identity provider still passes.
Concrete example
# The original token, decoded. Note: base64url, NOT encrypted — every claim is public.echo 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ' | base64 -d# {"alg":"RS256","kid":"1"}// ① alg: none — an empty signature, and some libraries accept it.{"alg":"none","typ":"JWT"}{"sub":"1042","role":"admin","exp":9999999999}// Token: eyJhbGciOiJub25lIn0.eyJzdWIiOiIxMDQyIiwicm9sZSI6ImFkbWluIn0.// ↑ nothing here# ② Algorithm confusion. The public key is PUBLIC — it is at /.well-known/jwks.json.curl -s https://app.example/.well-known/jwks.json > jwks.json # Change alg to HS256 and sign with that public key as the HMAC secret.python3 - <<'EOF'import jwtpub = open("public.pem").read()print(jwt.encode({"sub":"1042","role":"admin"}, pub, algorithm="HS256"))EOFThe server is configured for RS256. If it picks the algorithm from the token's alg, it calls HMAC-verify(token, publicKey) — and that matches.
// ③ No aud check. This token was signed by YOUR real IdP, for a different app.{"iss":"https://id.acme.com","aud":"internal-admin-tool","sub":"7","role":"admin"}// The signature is valid. If you only check the signature it passes — and "role":"admin"// means something in your app.// ④ jku — the library fetches the key from a URL in the header.{"alg":"RS256","jku":"https://evil.example/jwks.json"}// The signature matches the attacker's key, because the server fetched the attacker's key.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();What happened in the wild
CVE-2015-9235 — node-jsonwebtoken and a whole family of libraries. verify() picked the algorithm from the token's alg, so both alg: none and algorithm confusion worked. The same flaw appeared in Python, Ruby, PHP and Java libraries in the same period — the shared cause being an API that let you omit the algorithm list. An API design lesson: a required parameter does not get forgotten; an optional one does.
CVE-2022-21449 "Psychic Signatures" — Java 15–18. ECDSA signature verification accepted a signature of (r=0, s=0) as valid for any message and any key. So a token whose signature was all zeros verified successfully. Memorable because it shows the bug can live below the JWT library — and no application-level check catches it, only knowing which version you run.
And a recurring bug bounty shape: no aud validation. Many reports describe the same thing — an organisation with several apps behind one IdP, and the least important app's token being accepted by the most important app.
How to defend
A server-side algorithm allowlist, and all seven checks
mandatoryThe one-line principle: the algorithm and the key come from your configuration, not the token.
new TokenValidationParameters{ ValidAlgorithms = ["RS256"], // an allowlist; never read the token's alg ValidateIssuer = true, ValidIssuer = "https://id.acme.com", ValidateAudience = true, ValidAudience = "seclab-api", ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30), ValidateIssuerSigningKey = true, RequireSignedTokens = true, // closes alg: none RequireExpirationTime = true,}ValidAlgorithms is the most important line and it closes both alg: none and algorithm confusion in one place. Microsoft's default ClockSkew is five minutes — far too wide; set 30 seconds, and remember it adds to the token's real TTL.
aud and iss are the two most-skipped checks, and they are not details: across an organisation sharing one IdP, a missing aud means every app trusts every other app's tokens.
And jku/x5u must be ignored entirely. Never fetch a key from a URL inside the token. The JWKS comes from a configured endpoint, cached by kid, and an unknown kid is a rejection — not a lookup.
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;}Short TTLs, because JWTs cannot be revoked
mandatoryThis is an architectural consequence, not a tuning knob: verification without a database call means there is nothing to ask "is this token still valid".
- Access token: 5–15 minutes. That is the window in which a disabled account still works, and a leaked token still works. 24 hours means 24 hours of that.
- Refresh token: stored SERVER-side, and rotated on every use. The refresh token is the revocable part, and it is revocable only because it has state. Rotation plus reuse detection: an already-used refresh token appearing a second time means it was stolen — revoke the whole token family for that session.
- Refresh token in an
HttpOnly,Secure,SameSitecookie, notlocalStorage. See xss layer 3.
And the question to answer before choosing JWT at all: do you need immediate revocation? If you do (account lockout, password change, sign out everywhere) you need a store — and at that point an opaque session id is simpler, smaller, and revocable. JWT earns its place when you genuinely need stateless verification across many services.
Put nothing secret in the payload
eyJ... is base64url, not ciphertext. Every claim is public to anyone holding the token — including page JavaScript, including a proxy's access log.
So the payload should hold: sub, iss, aud, exp, iat, jti, and claims whose disclosure is acceptable (role, tenant). No email, phone number, PII, or internal state.
And a point about size: each added claim is bytes travelling with every request. A 4KB token exceeds some proxies' header limits, and it is 4KB times your request count.
When you genuinely need confidential claims: JWE (an encrypted JWT). But the prior question is why that information needs to travel through the client — usually it does not.
A revocation list for the cases needing immediate invalidation
This layer recovers part of the revocability JWT gave up, and it is a conscious trade-off: it adds a Redis call to the verify path, removing the main reason to use JWT — but only for events that genuinely need it.
- A
jtidenylist for specific revoked tokens, with a TTL equal to the token's remaining lifetime (so the list self-cleans and stays small). - Or cheaper: a per-user
tokensValidAfter. One timestamp in the database; a token whoseiatpredates it is rejected. One column, and it handles "password changed", "sign out everywhere" and "account locked" — the three events covering almost all real revocation needs.
Prefer the second where possible: it needs no Redis, and it revokes per person rather than per token — which is exactly what those events ask for.
Detection: an unexpected `alg` is an almost noise-free signal
Your legitimate clients send exactly one alg, and it does not change. So any other value is an attempt.
Three signals, all computable at the token verification point:
- An
algoutside the allowlist —none, orHS256when you use RS256. Alert on the first occurrence, not on a total. - A
kidabsent from the JWKS, or a header containingjku/x5u. Both essentially never appear in normal traffic. - An
audthat is not yours but with a valid signature. A particularly useful signal: it means somebody is trying another app's token from the same organisation — and it may equally be one of your own services misconfigured.
Log the iss, aud, kid and alg of every rejected token. Do not log the token — it is a credential, and logs are where credentials live longest.
Verifying the fix
1. A test taking the four payloads from block 4 as input data. The important part: alg: none and algorithm confusion need separate tests, because RequireSignedTokens closes the first and ValidAlgorithms closes the second — drop either and only one test goes red. See the csharp / test tab.
2. Check the configuration contains all seven validations — the check that catches omissions, and omission is this topic's primary failure mode:
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 "MISSING validation: $k"done# Microsoft's default ClockSkew is FIVE MINUTES — too wide, and it adds to the real TTL.grep -q "ClockSkew" /tmp/tvp.txt || echo "MISSING ClockSkew (defaults to 5 minutes)"3. Try it for real against staging. The only check that proves the library behaves as you believe:
B=https://staging.example.com# alg: none — must be 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 — sign with the public key as the HMAC secret. Must be 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. Test aud with a REAL token from another app. This check needs a real token, so it usually gets skipped — and it is the most common flaw in multi-app organisations. Take a token from another app behind the same IdP and assert it is rejected.
5. Check the TTL really is short:
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} minutes')sys.exit(0 if ttl<=15 else 1)" "$T" || echo "access token too long — JWTs cannot be revoked"6. Check there is no PII in the payload — the token is public, and so is everything in it:
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 in 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(); }}Common mistakes
| The "fix" | Why it is wrong |
|---|---|
Read alg from the token and pick the algorithm accordingly | That is the vulnerability. The algorithm must come from server config |
Block alg: none specifically | Closes one path, leaves algorithm confusion. ValidAlgorithms closes both |
| Check only the signature | Another app's token from the same IdP has a VALID signature. You need aud and iss |
Fetch the key from jku/x5u in the header | The attacker points it at their server. Keys come from a configured endpoint |
| Decode the payload client-side for authorisation | An unverified payload is attacker data. Authorise server-side |
| Put PII in the payload | base64url is not encryption. Every claim is public to anyone holding the token |
| A 24-hour access token TTL | JWTs cannot be revoked, so that is 24 hours a locked account keeps working |
Refresh token in localStorage | XSS reads it. An HttpOnly cookie does not — see the xss topic |
Leaving ClockSkew at its default | Microsoft's five minutes adds directly to every token's real TTL |
| Writing your own JWT verification | CVE-2022-21449 shows even the JDK got this wrong. Use a library, and know its version |
The architectural mistake, and it outweighs every row above: choosing JWT and then discovering you need immediate revocation. At that point people add a Redis denylist to the verify path — removing the very reason to use JWT. The question to answer first: do you need stateless verification across many services? If not, an opaque session id is smaller, simpler, and revocable.
The trust mistake: thinking JWT is a security mechanism. It is a format. The security comes from the seven checks in block 3 — and four of the seven are commonly skipped.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…