JWT verification uses .verify() not just .decode()
Why it matters
jwt.decode() in jsonwebtoken does not verify the signature — it base64-decodes the payload without checking the HMAC or RSA signature at all. An attacker can construct a JWT with any payload they want, including { role: 'admin', userId: '1' }, and your application will accept it as a valid authenticated identity. CWE-347 (Improper Verification of Cryptographic Signature) and OWASP A07 (Identification & Authentication Failures) both cite this exact pattern. Authentication built on jwt.decode() provides zero security — every token is trusted unconditionally.
Severity rationale
Critical because using `.decode()` instead of `.verify()` makes JWT-based authentication trivially bypassable — any attacker can forge a token with arbitrary claims and gain full authenticated access.
Remediation
Replace every jwt.decode(token) that feeds into user identity with jwt.verify(token, secret). The verify call will throw if the signature is invalid or the token is expired.
// Bad: trusts payload without signature check
import jwt from 'jsonwebtoken'
const payload = jwt.decode(token) // no signature verification!
req.user = payload
// Good: verify the signature before trusting the payload
import jwt from 'jsonwebtoken'
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!)
req.user = payload
} catch (err) {
return Response.json({ error: 'Invalid or expired token' }, { status: 401 })
}
.decode() is only safe when inspecting the token header (e.g., extracting the kid claim to select the right key) BEFORE a subsequent .verify() call. It must never be the final word on identity.
Detection
-
ID:
jwt-verify-not-decode -
Severity:
critical -
What to look for: When any JWT library is in
package.jsondependencies (jsonwebtoken,jose,@panva/jose,next-auth/jwt,jose-jwe-jws), walk source files for usages of the library. For each call site, count all occurrences of.decode(calls and.verify(calls. Before evaluating, extract and quote each.decode(and.verify(line. A.decode(call followed by use of the decoded payload (assigned toreq.user,session, returned from agetCurrentUser, etc.) is the canonical "verify shortcut" anti-pattern. Count all files where.decode(is used to derive identity AND no corresponding.verify(is called in the same logical flow. -
Detector snippet (shell-capable tools only): If
rgis available, list every source file that calls.decode(on a JWT library. Exit 0 with output means at least one call site exists — proceed to prose to confirm the decoded payload is actually used as identity (and not verified elsewhere). Exit 1 with no output means no.decode(call sites — likely pass. Exit >=2 — fall back to prose reasoning.rg -l -e "jwt\.decode\(" -e "jsonwebtoken['\"].*\.decode\(" src/ -
Pass criteria: 100% of JWT-derived identity assignments use
.verify((not.decode(). Report: "JWT library: [name]. X JWT call sites, Y use .verify(), 0 use .decode() to derive identity." -
Fail criteria: At least 1 source file uses
.decode(to extract user identity from a JWT without a corresponding.verify(call. -
Do NOT pass when:
.decode(is called with{ complete: true }to inspect the header BEFORE a separate.verify(call — that's safe. The fail is.decode(followed by trusting the payload without.verify(. -
Skip (N/A) when: No JWT library is in
package.jsondependencies. -
Cross-reference: For broader authentication security analysis, the Security Hardening audit (
security-hardening) covers JWT patterns in depth. -
Detail on fail:
"1 JWT decode-without-verify: src/lib/auth.ts line 12 calls jwt.decode(token) and assigns the payload to req.user without ever calling jwt.verify(). This is auth bypass — any forged JWT is accepted." -
Remediation:
jwt.decode()does NOT verify the signature — it just base64-decodes the payload. Anyone can forge a JWT and have it pass. Always usejwt.verify():// Bad: decode trusts the payload import jwt from 'jsonwebtoken' const payload = jwt.decode(token) // no signature check! req.user = payload // Good: verify checks the signature import jwt from 'jsonwebtoken' try { const payload = jwt.verify(token, process.env.JWT_SECRET!) req.user = payload } catch (err) { return Response.json({ error: 'Invalid token' }, { status: 401 }) }
External references
- cwe · CWE-347 — Improper Verification of Cryptographic Signature
- cwe · CWE-345 — Insufficient Verification of Data Authenticity
- owasp:2021 · A02 — Cryptographic Failures
- owasp:2021 · A07 — Identification and Authentication Failures
Taxons
History
- 2026-04-18·v1.0.0·Initial import from ai-slop-security-theater·automated
- 2026-04-20·v1.1.0·Add Phase 6.0 detect-rg snippet for jwt.decode usage detection·by cakleinman