jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Security for Frontend Devs · Part 15 — JWT & Token Attacks

Advanced track: JWT attacks to recognize — alg:none, RS256→HS256 confusion, weak HMAC secrets, kid/jku injection, unchecked exp/aud/iss — why the browser must never trust a claim. With a forge/verify simulator and exercises.

Part 15 — Advanced track in the Web Security for Frontend Devs series {Phần 15 — Nhánh nâng cao trong series Web Security for Frontend Devs}. Previous {Trước}: Part 14 — postMessage & Cross-Window Exploits.

Part 5 covered where to store tokens {Phần 5 nói lưu token ở đâu}. This advanced part covers how tokens are forged and abused {Phần nâng cao này nói token bị giả mạo và lạm dụng thế nào}. Most JWT breaks are verification mistakes on the server, but as the frontend engineer you are often the one who reads claims, passes tokens between windows (Part 14), and reviews the auth code — so you must recognize these patterns {Hầu hết lỗi JWT là sai sót verify trên server, nhưng frontend thường là người đọc claim, chuyển token giữa cửa sổ (Phần 14), và review code auth — nên bạn phải nhận ra các mẫu này}.


A JWT in 30 seconds {JWT trong 30 giây}

A JWT is three base64url parts: header.payload.signature {JWT gồm ba phần base64url}:

eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiIxIiwicm9sZSI6InVzZXIifQ . 3a8f…sig
   {"alg":"HS256"}         {"sub":"1","role":"user"}          HMAC(header.payload, secret)

Two facts drive every attack {Hai sự thật chi phối mọi tấn công}:

  1. The header and payload are not secret — anyone can base64url-decode and read them {Header và payload không bí mật — ai cũng decode được}.
  2. Only the signature makes the token trustworthy, and only if the server verifies it correctly — with the right algorithm and the right key {Chỉ chữ ký khiến token đáng tin, và chỉ khi server verify đúng — đúng thuật toán và đúng khóa}.

Every attack below makes the server accept a token it should reject {Mọi tấn công dưới đây khiến server nhận token đáng lẽ phải từ chối}.


Attack 1 — alg: none {Tấn công 1 — alg: none}

The JWT spec defines an none “unsecured” algorithm — a token with no signature {Spec JWT định nghĩa thuật toán none “không bảo mật” — token không chữ ký}. A naive verifier that trusts the header’s alg will skip signature checking entirely {Verifier ngây thơ tin alg trong header sẽ bỏ qua kiểm chữ ký}:

header  = {"alg":"none","typ":"JWT"}
payload = {"sub":"1","role":"admin"}      ← attacker sets whatever they want
token   = base64url(header) + "." + base64url(payload) + "."   ← empty signature

If accepted, the attacker is now role: admin with a token they wrote by hand {Nếu được nhận, kẻ tấn công trở thành role: admin với token tự viết tay}. Fix: pin the expected algorithm; never let the token’s header choose it {Sửa: ghim thuật toán mong đợi; đừng để header token chọn}.


Attack 2 — RS256 → HS256 algorithm confusion {Tấn công 2 — nhầm thuật toán RS256 → HS256}

Asymmetric RS256 signs with a private key and verifies with a public key (which is, by design, public) {RS256 ký bằng khóa riêng và verify bằng khóa công khai (vốn công khai)}. If the server calls a generic verify(token, key) and lets the token pick the algorithm, an attacker changes alg to HS256 and signs with the public key as the HMAC secret {Nếu server gọi verify(token, key) chung và để token chọn thuật toán, kẻ tấn công đổi alg sang HS256 và ký bằng khóa công khai làm secret HMAC}:

1. attacker fetches the server's RS256 PUBLIC key (often at /.well-known/jwks.json)
2. crafts {"alg":"HS256"} with chosen claims
3. signs: HMAC-SHA256(header.payload, PUBLIC_KEY_BYTES)
4. server verifies HS256 using the public key it has on hand → ✓ valid

The server thinks it is verifying an RSA signature; instead it computes an HMAC with a key the attacker also knows {Server tưởng đang verify chữ ký RSA; thực ra tính HMAC với khóa kẻ tấn công cũng biết}. Fix: pin RS256 (or your chosen alg) explicitly and reject any other; never feed a public key into an HMAC verifier {Sửa: ghim RS256 rõ và từ chối khác; đừng đưa khóa công khai vào verifier HMAC}.


Attack 3 — weak HMAC secret {Tấn công 3 — secret HMAC yếu}

HS256 security is entirely the secret’s entropy {An toàn HS256 hoàn toàn nằm ở entropy của secret}. A secret like secret, changeme, or a leaked .env value is offline-brute-forceable: the attacker has the signed token, runs a wordlist until an HMAC matches, then forges arbitrary tokens {Secret như secret, changeme, hay .env rò rỉ có thể brute-force offline: kẻ tấn công có token đã ký, chạy wordlist đến khi HMAC khớp, rồi giả mạo token tùy ý}. Fix: use a long, random secret (≥ 256 bits) from a secrets manager; rotate on leak; never commit it (Part 9) {Sửa: dùng secret dài, ngẫu nhiên (≥ 256 bit) từ secrets manager; xoay khi rò; không commit (Phần 9)}.


Attack 4 — kid / jku / x5u header injection {Tấn công 4 — tiêm header kid / jku / x5u}

JOSE headers can tell the verifier which key to use {Header JOSE có thể bảo verifier dùng khóa nào}:

  • kid (key id) is often used to look a key up — if it is concatenated into a file path or SQL query, it enables path traversal (kid: "../../dev/null" → empty key) or SQL injection to control the verifying key {kid thường dùng tra khóa — nếu nối vào path file hay SQL, mở path traversal hoặc SQLi để kiểm soát khóa verify}.
  • jku / x5u point to a URL for the key set — if the server fetches it without an allowlist, the attacker hosts their own keys and signs valid-looking tokens (also an SSRF vector) {jku/x5u trỏ URL key set — nếu server fetch không allowlist, kẻ tấn công host khóa của họ và ký token trông hợp lệ (cũng là SSRF)}.

Fix: treat all header-supplied key references as untrusted — allowlist kid values, never use them in paths/queries, and fetch keys only from pinned, trusted endpoints {Sửa: coi mọi tham chiếu khóa từ header là không tin cậy — allowlist kid, không dùng trong path/query, chỉ fetch khóa từ endpoint ghim sẵn}.


Attack 5 — unchecked exp / nbf / aud / iss {Tấn công 5 — không kiểm exp / nbf / aud / iss}

A valid signature does not mean a valid session {Chữ ký hợp lệ không nghĩa là phiên hợp lệ}. Servers that verify the signature but skip claim checks accept {Server verify chữ ký nhưng bỏ kiểm claim sẽ nhận}:

  • Expired tokens (exp in the past) — replay after logout {token hết hạn — replay sau logout}.
  • Tokens for a different audience/issuer (aud/iss) — a token minted for service A reused on service B {token sai audience/issuer — token của dịch vụ A dùng lại ở B}.

Fix: always validate exp, nbf, aud, and iss against expected values, with small clock skew {Sửa: luôn validate exp, nbf, aud, iss theo giá trị mong đợi, với sai số đồng hồ nhỏ}.


The frontend angle: never trust a claim in the browser {Góc frontend: đừng tin claim trong trình duyệt}

Decoding a JWT client-side to show a name or proactively log out near exp is fine — that is UX {Decode JWT phía client để hiện tên hay logout sớm gần exp thì ổn — đó là UX}. But the browser can be edited by the user (Part 10), so {Nhưng trình duyệt bị user sửa được (Phần 10), nên}:

// ❌ security decision in the browser — trivially bypassed in DevTools
const { role } = jwtDecode(token);
if (role === 'admin') showAdminPanel(); // attacker just edits the decoded object
// ✅ client decode is UX only; the SERVER authorizes every privileged action
const { name, exp } = jwtDecode<{ name: string; exp: number }>(token);
renderGreeting(name);
if (Date.now() / 1000 > exp - 30) scheduleSilentRefresh();
// admin actions call an API that re-verifies the token and checks role server-side

Also: never put secrets in a JWT payload (it is readable), and never log full tokens — they are bearer credentials {Cũng: không để secret trong payload JWT (đọc được), và không log token đầy đủ — chúng là credential bearer}.


Try it — JWT forge & verify simulator {Thử ngay — trình mô phỏng giả mạo & verify JWT}

Start from a sample token, apply an attack (alg:none, tamper a claim, re-sign with a weak secret), then configure a verifier (pin algorithm? verify signature? strong secret? check exp?) and see whether the forged token is accepted or rejected — and why {Bắt đầu từ token mẫu, áp dụng tấn công, rồi cấu hình verifier và xem token giả mạo được nhận hay từ chối — và vì sao}.

Open the full demo {Mở demo đầy đủ}: /tools/jwt-attacks-demo/.


Prevention checklist {Checklist phòng tránh}

  1. Pin the algorithm in the verifier; reject none and any unexpected alg {Ghim thuật toán; từ chối nonealg bất ngờ}.
  2. Use the right key type for the alg — never verify HMAC with an asymmetric public key {Dùng đúng loại khóa; không verify HMAC bằng khóa công khai}.
  3. Strong, rotated HMAC secrets (≥ 256-bit); keep them out of the repo (Part 9) {Secret HMAC mạnh, có xoay; ngoài repo (Phần 9)}.
  4. Treat kid/jku/x5u as untrusted: allowlist, no path/SQL use, pinned key endpoints {Coi kid/jku/x5u không tin cậy: allowlist, không path/SQL, endpoint ghim}.
  5. Validate exp/nbf/aud/iss on every request {Validate exp/nbf/aud/iss mỗi request}.
  6. Client-side decode is UX only — the server authorizes; no secrets in payloads; don’t log tokens {Decode client chỉ UX — server authorize; không secret trong payload; không log token}.

Bài tập / Exercises

1. A verifier does jwt.verify(token, publicKey) without specifying algorithms, using an RS256 keypair. Explain the forgery and the one-option fix. {Verifier gọi jwt.verify(token, publicKey) không chỉ thuật toán, dùng cặp khóa RS256. Giải thích cách giả mạo và cách sửa một-tuỳ-chọn.}

Solution {Lời giải}

The attacker sets alg: HS256 and signs the token with the public key bytes as the HMAC secret; the library, told to “verify with publicKey”, computes an HMAC and it matches {Kẻ tấn công đặt alg: HS256 và ký bằng byte khóa công khai làm secret HMAC; thư viện “verify với publicKey” tính HMAC và khớp}. Fix: pass { algorithms: ['RS256'] } so the library rejects the HS256 token outright {Sửa: truyền { algorithms: ['RS256'] } để thư viện từ chối token HS256}.

2. Why is decoding role from a JWT in React to gate an admin route insufficient, even if the JWT is properly signed? {Vì sao decode role trong React để chặn route admin là không đủ, kể cả khi JWT ký đúng?}

Solution {Lời giải}

The route guard runs in the browser, which the user fully controls — they can patch the decoded object, the bundle, or call the underlying API directly {Guard chạy trong trình duyệt mà user kiểm soát hoàn toàn}. Hiding the UI is fine for UX, but the API behind the admin actions must re-verify the token and check role server-side {Ẩn UI ổn cho UX, nhưng API sau hành động admin phải verify lại token và kiểm role phía server}. Otherwise it is access control on the client = no access control {Nếu không thì là kiểm soát truy cập trên client = không có kiểm soát}.

3. Your team uses HS256 with the secret "prod-secret-2021". List two things wrong and the remediation. {Team dùng HS256 với secret "prod-secret-2021". Nêu hai điều sai và cách khắc phục.}

Solution {Lời giải}

(1) Low entropy — a guessable, human-readable secret is brute-forceable offline from any captured token {Entropy thấp — secret đoán được, brute-force offline}. (2) It looks committed/long-lived (a year in the name) and likely shared {Trông như commit/sống lâu và bị chia sẻ}. Remediation: replace with a ≥ 256-bit random secret from a secrets manager, rotate it (invalidating old tokens), and remove it from any repo/history {Thay bằng secret ngẫu nhiên ≥ 256-bit từ secrets manager, xoay (vô hiệu token cũ), và xóa khỏi repo/history}.

Stretch {Nâng cao}: In the simulator, craft an alg:none token granting role: admin, accept it against the “trusts header alg” verifier, then switch the verifier to “pin HS256” and confirm rejection {Trong simulator, tạo token alg:none cấp role: admin, cho qua verifier “tin header alg”, rồi đổi sang “ghim HS256” và xác nhận bị từ chối}.


Key takeaways {Điểm chính}

  • A JWT is trustworthy only if verified with the right algorithm and key — header and payload are public {JWT đáng tin chỉ khi verify đúng thuật toán và khóa — header/payload công khai}.
  • alg:none and RS256→HS256 confusion both come from letting the token choose the algorithm — pin it {alg:nonenhầm RS256→HS256 đều do để token chọn thuật toán — hãy ghim}.
  • Weak HMAC secrets and kid/jku injection hand the attacker the signing/verifying key {Secret HMAC yếutiêm kid/jku trao khóa cho kẻ tấn công}.
  • Always validate exp/nbf/aud/iss — a valid signature is not a valid session {Luôn validate exp/nbf/aud/iss}.
  • In the browser, JWT decode is UX only; the server makes every authorization decision {Trong trình duyệt, decode JWT chỉ UX; server quyết định mọi authorization}.

Next up {Tiếp theo}

The advanced track closes with Web Cache Deception & Open-Redirect Chaining — how caching layers and trusting redirect targets leak private responses and chain into account takeover {Nhánh nâng cao khép lại với Web Cache Deception & Open-Redirect Chaining — cách tầng cache và tin URL redirect làm rò phản hồi riêng tư và chuỗi thành chiếm tài khoản}. Continue to Part 16 — Web Cache Deception & Open-Redirect Chaining.