# JWT verification uses .verify() not just .decode()

- **Pattern:** `ab-000288` (`ai-slop-security-theater.half-wired-crypto.jwt-verify-not-decode`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-20
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000288
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000288)

## 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.

```ts
// 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.json` dependencies (`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 to `req.user`, `session`, returned from a `getCurrentUser`, 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 `rg` is 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.

  ```detect-rg
  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.json` dependencies.
- **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 use `jwt.verify()`:

  ```ts
  // 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
- cwe CWE-345
- owasp A02
- owasp A07

Taxons: cryptography-and-secrets, access-control

HTML version: https://auditbuffet.com/patterns/ab-000288
