An auth middleware verifying JWTs. The line looked correct — uses the official `jsonwebtoken` library, calls `verify`, passes the secret. The bug was what wasn't there: the algorithms option. Without it, the library accepts whatever algorithm the token header claims — including `none`.
The vulnerable diff
// auth/middleware.ts
import jwt from "jsonwebtoken";
export function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
// BUG: no algorithms option — library accepts alg:none
const decoded = jwt.verify(token, SECRET);
req.user = decoded;
next();
}What is wrong
The `jsonwebtoken` library historically defaulted to honoring whatever algorithm the JWT header specified. Without an explicit `algorithms` allowlist, an attacker crafts a token with `{"alg":"none","typ":"JWT"}` and an empty signature; the library treats the token as valid because `alg:none` requires no signature. Newer versions of the library warn about this, but the bug class is still common in older codebases and even in some 2026 PRs. CWE-347 covers this and the related `alg` confusion attacks (HS256/RS256 switch).
The attack
Forging a token takes one line of code:
// Attacker forges an admin JWT — no signature needed
const header = Buffer.from(JSON.stringify({alg:"none", typ:"JWT"})).toString("base64url");
const payload = Buffer.from(JSON.stringify({sub:"admin", role:"admin", exp: Date.now()/1000 + 3600})).toString("base64url");
const forged = `${header}.${payload}.`; // empty signature
await fetch("/api/admin/users", { headers: { authorization: `Bearer ${forged}` }});
// 200 OK — server-side jwt.verify accepted it.From there the attacker is admin. Variants include `alg:HS256` with the RS256 public key as the HMAC secret — a different but related attack class.
Mesrai's review comment
mesraipilot · Bot · reviewed 90 sec ago
[mesrai] [code-review] [Security] [CWE-347] [critical]
`jwt.verify(token, SECRET)` without an `algorithms` option lets the
library accept `alg:none` tokens. Attacker forges a token with header
`{"alg":"none"}` and empty signature — server accepts it as valid.
Always pin the algorithm:
const decoded = jwt.verify(token, SECRET, {
algorithms: ["HS256"],
issuer: "app.example",
audience: "api.example",
});
Also pin issuer and audience to prevent token reuse across services.
For asymmetric (RS256/ES256) keys, pass the public key — never accept
a mix of HS and RS in the same allowlist.
Reference: OWASP JWT Cheat Sheet, CWE-347The fix
// auth/middleware.ts — fixed
const decoded = jwt.verify(token, SECRET, {
algorithms: ["HS256"],
issuer: "app.example",
audience: "api.example",
});Three options: pin the algorithm to a single value matching how you sign tokens, pin the issuer so tokens from other services do not validate here, pin the audience so this token cannot be replayed against a sister service. All three are one-line additions.
Why human review missed it
JWT verification looks safe because it uses a vetted library. Reviewers trust the library's defaults. The defaults changed across `jsonwebtoken` major versions, so the same code is safe on one version and unsafe on the previous one. Mesrai's rule pack catches every `jwt.verify` call without an explicit `algorithms` parameter regardless of library version.
Related rules + further reading
Mesrai rule pack: security/jwt-algorithms-pinned — flags `jwt.verify` without `algorithms` allowlist.
OWASP: JSON Web Token Cheat Sheet.
CWE-347 — Improper Verification of Cryptographic Signature.
Takeaway
Pin the algorithm. Pin the issuer. Pin the audience. Three options, two seconds to write, full class of JWT bypass closed.