JWT verification mistakes
A JWT you can read is not a JWT you can trust. The signature is the only thing that ties a token's claims to the party that issued it, and most real-world JWT bugs are verifiers that skip it, weaken it, or let the token decide how it is checked. This note builds a deliberately flawed verifier, breaks it with four forged or misused tokens, and then fixes it. Everything uses Node.js built-ins (node:crypto), and every output below was produced by running the code on the version in the byline.
This is a teaching example, not a library. In production, use a maintained JWT library and configure the allowed algorithms, issuer, and audience yourself. We did not test any library (see the end).
Decoding is not verifying
A signed JWT is three Base64url segments separated by dots: header, payload, signature. The first two are plain JSON. Anyone who has the token can read them, and anyone can write a new one:
$ node decode.mjs eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxOTAwMDAwMDAwfQ.c2lnbmF0dXJl
{ sub: 'alice', role: 'user', exp: 1900000000 }No key was needed. Decoding tells you what a token claims. Only a signature check made with a key and an algorithm that you chose tells you who said it. A signed JWT is also not encrypted: do not put secrets in the payload.
A verifier that trusts the token
Here is the mistake in its most common form. The code reads alg from the token's own header and picks the verification method accordingly:
import { createHmac, createVerify } from "node:crypto";
import { parse } from "./lib.mjs";
// WRONG: lets the token choose how it is verified.
export function naiveVerify(token, key) {
const { header, claims, signature, signingInput } = parse(token);
switch (header.alg) {
case "none":
return claims; // "unsecured JWT": no signature to check
case "HS256": {
const mac = createHmac("sha256", key).update(signingInput).digest("base64url");
if (mac !== signature) throw new Error("bad signature");
return claims;
}
case "RS256": {
const ok = createVerify("sha256").update(signingInput).verify(key, signature, "base64url");
if (!ok) throw new Error("bad signature");
return claims;
}
default:
throw new Error(`unsupported alg ${header.alg}`);
}
}The helpers (parse, signRS256) and the demo that produces the results below are at the end of this note. Using the one RSA key pair of a demo issuer, we created six tokens and ran both verifiers on each.
Attack 1: alg: none
JWT defines an unsecured form whose header says "alg":"none" and whose signature segment is empty (RFC 7519 §6). A verifier that honors the header accepts any claims the sender writes. In our run, the forged token with role=admin and no signature was accepted. RFC 8725 describes this class of problem as insufficient signature validation (§2.1) and recommends the verifier fix the acceptable algorithms (§3.1, §3.2).
Attack 2: algorithm confusion (RS256 → HS256)
The verifier is configured with the issuer's RSA public key. With alg: HS256 in the header, our flawed code uses that same key variable as an HMAC secret. A public key is, by definition, public, so an attacker can sign with it. The forged HS256 token, signed with the public key's PEM text, was accepted with role=admin. The root cause is the same as above: the token chose the algorithm, so the key meant for one algorithm was used with another. Whether a given library is vulnerable depends on its version and how you call it; we did not test any.
Attack 3: a valid signature on the wrong claims
Not every forged token is forged. Two tokens in the run were correctly signed by the real issuer but should still be rejected: one expired an hour ago, and one was issued for a different service (aud is billing-api, not orders-api). The flawed verifier accepted both, because it only checks the signature. RFC 7519 §4.1.4 says an expired token must not be accepted on or after its exp time. Audience and issuer checks are covered in RFC 8725 §3.9 and §3.8. Without them, a token for service A works on service B.
The sixth token shows what the signature does protect: editing the payload of a legitimate token (role changed to admin) while keeping its signature was rejected by both verifiers.
The fix: the verifier decides, the token supplies data
import { createVerify } from "node:crypto";
import { parse } from "./lib.mjs";
// The verifier decides the algorithm and the key; the token only supplies data.
export function verifyRS256(token, { publicKey, issuer, audience, now = Math.floor(Date.now() / 1000) }) {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("not a compact JWS");
const { header, claims, signature, signingInput } = parse(token);
if (header.alg !== "RS256") throw new Error(`alg ${header.alg} is not allowed`);
const ok = createVerify("sha256").update(signingInput).verify(publicKey, signature, "base64url");
if (!ok) throw new Error("bad signature");
if (typeof claims.exp !== "number" || now >= claims.exp) throw new Error("expired or missing exp");
if (typeof claims.nbf === "number" && now < claims.nbf) throw new Error("not yet valid");
if (claims.iss !== issuer) throw new Error("wrong issuer");
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (!aud.includes(audience)) throw new Error("wrong audience");
return claims;
}The differences from the flawed version:
- The algorithm is pinned in the code, not read from the token. The header is only compared against the expectation (RFC 8725 §3.1).
- The key type matches the algorithm. The public key is only ever passed to an RSA verification, so it can never be used as an HMAC secret.
- Claims are validated after the signature:
exp,nbf,iss,aud. - A token without
expis rejected. RFC 7519 makesexpoptional, so this is our policy choice, not a rule of the specification. Choose deliberately.
Result for the same six tokens:
naive verifier
legitimate token ACCEPTED role=user
alg none, role=admin ACCEPTED role=admin
HS256 signed with the public key ACCEPTED role=admin
payload edited, signature kept rejected: bad signature
expired (valid signature) ACCEPTED role=user
other audience (valid signature) ACCEPTED role=user
strict verifier
legitimate token ACCEPTED role=user
alg none, role=admin rejected: alg none is not allowed
HS256 signed with the public key rejected: alg HS256 is not allowed
payload edited, signature kept rejected: bad signature
expired (valid signature) rejected: expired or missing exp
other audience (valid signature) rejected: wrong audienceOnly the legitimate token passes. Note the strict verifier says nothing about the correct way to generate or rotate keys; it only demonstrates the verification rules.
More things to check (not tested here)
- Header-driven key lookup. Fields such as
kid,jku, andx5uin the header tell a verifier where to find a key. If they are trusted blindly they can lead the server to attacker-controlled keys or lookups (RFC 8725 §2.9). Resolve keys from a fixed allow-list. - Cross-JWT confusion. A token issued for one purpose being accepted for another. Use explicit
typvalues and separate validation rules per kind of token (RFC 8725 §2.8, §3.11, §3.12). - HMAC secrets and comparison. If you use HS256, the secret must have enough entropy (RFC 8725 §3.5), and a hand-written MAC comparison should use a constant-time function such as
crypto.timingSafeEqual. We did not measure timing. - Clock skew. Allow a small, explicit leeway for
expandnbfif your servers' clocks differ. Our example uses none. - Revocation. A signed, unexpired token stays valid until
exp. Signature verification does not cover logout or revoked access.
Checklist
- Fix the accepted algorithm(s) in configuration; never branch on the token's
alg. - Use a key of the matching type for each algorithm.
- Verify the signature before reading any claim.
- Require and check
exp; checknbf,iss, andaudagainst values you configured. - Do not trust header fields that select keys.
- Use a maintained library, and write tests that feed it the forged tokens above.
Reproduce it
Save the files below in one folder (with naive.mjs and strict.mjs from above) and run node demo.mjs. The tokens carry fresh timestamps, so only the pass/fail lines are stable.
lib.mjs (helpers)
import { createHmac, createSign, createVerify, generateKeyPairSync, timingSafeEqual } from "node:crypto";
export const b64u = (value) => Buffer.from(typeof value === "string" ? value : JSON.stringify(value)).toString("base64url");
export function parse(token) {
const [h, p, s] = token.split(".");
return {
header: JSON.parse(Buffer.from(h, "base64url")),
claims: JSON.parse(Buffer.from(p, "base64url")),
signature: s,
signingInput: `${h}.${p}`,
};
}
export function signRS256(claims, privateKey) {
const signingInput = `${b64u({ alg: "RS256", typ: "JWT" })}.${b64u(claims)}`;
const signature = createSign("sha256").update(signingInput).sign(privateKey, "base64url");
return `${signingInput}.${signature}`;
}demo.mjs
import { createHmac, generateKeyPairSync } from "node:crypto";
import { b64u, signRS256 } from "./lib.mjs";
import { naiveVerify } from "./naive.mjs";
import { verifyRS256 } from "./strict.mjs";
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
const now = Math.floor(Date.now() / 1000);
const base = { sub: "alice", role: "user", iss: "https://issuer.example", aud: "orders-api", exp: now + 300 };
const tokens = {
"legitimate token": signRS256(base, privateKey),
"alg none, role=admin": `${b64u({ alg: "none", typ: "JWT" })}.${b64u({ ...base, role: "admin" })}.`,
"HS256 signed with the public key": (() => {
const input = `${b64u({ alg: "HS256", typ: "JWT" })}.${b64u({ ...base, role: "admin" })}`;
return `${input}.${createHmac("sha256", publicKey).update(input).digest("base64url")}`;
})(),
"payload edited, signature kept": (() => {
const [h, , sig] = signRS256(base, privateKey).split(".");
return `${h}.${b64u({ ...base, role: "admin" })}.${sig}`;
})(),
"expired (valid signature)": signRS256({ ...base, exp: now - 3600 }, privateKey),
"other audience (valid signature)": signRS256({ ...base, aud: "billing-api" }, privateKey),
};
const run = (verify, token) => {
try {
const c = verify(token);
return `ACCEPTED role=${c.role}`;
} catch (e) {
return `rejected: ${e.message}`;
}
};
console.log("naive verifier");
for (const [name, token] of Object.entries(tokens)) console.log(` ${name.padEnd(36)} ${run((t) => naiveVerify(t, publicKey), token)}`);
console.log("strict verifier");
for (const [name, token] of Object.entries(tokens))
console.log(` ${name.padEnd(36)} ${run((t) => verifyRS256(t, { publicKey, issuer: "https://issuer.example", audience: "orders-api" }), token)}`);decode.mjs (used in the first section)
import { parse } from "./lib.mjs";
console.log(parse(process.argv[2]).claims);What we did not verify
We ran the code only on Node.js v24.7.0. We did not test any JWT library, so we make no claim about which libraries or versions are vulnerable to the attacks above. We did not test kid/jku attacks, other algorithms (HS384, ES256, PS256, and so on), encrypted JWTs (JWE), or timing behavior. The strict verifier is a minimal demonstration, not a complete implementation.
References
- RFC 7519: JSON Web Token (JWT) (§4.1 registered claims, §6 unsecured JWTs, §7.2 validating a JWT)
- RFC 7515: JSON Web Signature (JWS)
- RFC 8725: JSON Web Token Best Current Practices (§2 threats, §3 best practices)
- Node.js documentation: crypto
Related tools
- JWT Decoder — Decodes the header and payload locally and does not verify the signature. Use it to inspect a token, not to decide whether to trust it.
- JWT Expiration Checker — Compares the exp claim with your device clock. It does not verify the signature either.
Small Tools is built and operated by ZIV LABS. These links go to tools.ziv-labs.com.
Related notes
- JSON validation pitfalls — Duplicate keys, large integers, BOMs, and NaN: how JSON parsers in JavaScript and Python disagree, with commands you can run to reproduce each result.