DEV Community

Charles Kern
Charles Kern

Posted on Originally published at safeweave.dev

Why Cursor Decodes JWTs Instead of Verifying Them (CWE-347)

TL;DR

  • AI editors regularly write auth middleware that calls jwt.decode() where it needs jwt.verify(), so the token's signature is never checked.
  • Anyone can then edit the payload, set "role": "admin", and walk in. The forged token never has to be signed.
  • The fix: verify every token with a pinned algorithm list, and use jose where jsonwebtoken will not run.

I asked Cursor to add route protection to a small Next.js app last week. The first version imported jsonwebtoken into middleware.ts, and the build failed because the Edge runtime has no Node crypto module.

So I told Cursor to fix the build error. It did. It swapped jwt.verify() for jwt.decode(), the build went green, the protected pages still redirected logged-out users, and every manual test passed.

The app was now accepting any token anyone could type.

The Vulnerable Code

jwt.decode() reads a token's payload without checking its signature, so any middleware that makes an auth decision from it trusts data the client wrote. This is CWE-347, Improper Verification of Cryptographic Signature.

Here is the middleware Cursor produced after "fixing" the build:

// middleware.ts  (CWE-347: signature never verified)
import { NextResponse } from 'next/server';
import { decode } from 'jsonwebtoken';

export function middleware(req) {
  const token = req.cookies.get('session')?.value;
  const payload = token ? decode(token) : null;

  if (!payload || payload.exp * 1000 < Date.now()) {
    return NextResponse.redirect(new URL('/login', req.url));
  }
  if (req.nextUrl.pathname.startsWith('/admin') && payload.role !== 'admin') {
    return NextResponse.redirect(new URL('/', req.url));
  }
  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

It checks expiry. It checks the role. It looks like an auth layer. But a JWT is just three base64url segments, and the first two are readable and editable by anyone. Take a real session token, decode the middle segment, change "role": "user" to "role": "admin", push the expiry out a year, re-encode it, and paste it back into the cookie. The signature segment no longer matches. Nothing checks.

The jsonwebtoken README is blunt about this. It says decode returns the payload "without verifying if the signature is valid" and that you should not use it for untrusted messages. Every cookie is an untrusted message.

The Python version shows up too, usually after someone hits a DecodeError and asks the editor to make it go away:

# CWE-347: signature verification switched off
payload = jwt.decode(token, options={"verify_signature": False})
if payload.get("role") == "admin":
    ...
Enter fullscreen mode Exit fullscreen mode

PyJWT documents that option for reading claims you are not going to trust. It even warns that without the signature, the integrity of the claims "cannot be trusted." The editor reaches for it because it makes the exception disappear.

Why This Keeps Happening

AI editors optimize for making the error you showed them go away, and decode() is the shortest path from "this throws" to "this runs." It has the same import, nearly the same name, and returns the same object. From the editor's point of view the swap is a one-word fix.

Three things push it there.

The runtime constraint is real. jsonwebtoken depends on Node's crypto, so it fails to bundle for the Edge runtime that Next.js middleware has historically used. The correct answer is a different library. The easy answer is a different function in the same library.

Nothing in your tests can tell them apart. Every test you would naturally run uses a legitimately signed token, and decode() and verify() return the identical payload for a legitimate token. They only diverge for a forged one. Security controls are invisible on success, and nobody writes the forged-token test while they are trying to get the build green.

The training data is full of decode(). Debug snippets, "read the user ID on the client" examples, and tutorials that peek at a token's claims all use decode() correctly, for display. The editor learned the call without the context that made it safe.

There is also an older version of this bug worth knowing, because editors still pin old versions. In jsonwebtoken 8.5.1 and below, calling verify() with no algorithms option and a falsy key (an unset env var, for example) could fall back to the none algorithm and accept unsigned tokens. That is CVE-2022-23540, fixed in 9.0.0. A separate advisory in the same range, CVE-2022-23541, covered RS256 tokens being verified as HS256 when one key-lookup function served both key types. If your editor writes "jsonwebtoken": "^8.5.1" into package.json, you get both.

The Fix

Verify the signature on every request that makes an auth decision, pin the algorithm you actually use, and fail closed on any error. Decoding is only for display, never for access control.

For Next.js middleware on the Edge runtime, use jose, which runs on Web Crypto:

// middleware.ts
import { NextResponse } from 'next/server';
import { jwtVerify } from 'jose';

const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET is not set');
const key = new TextEncoder().encode(secret);

export async function middleware(req) {
  const token = req.cookies.get('session')?.value;
  if (!token) return NextResponse.redirect(new URL('/login', req.url));

  try {
    const { payload } = await jwtVerify(token, key, {
      algorithms: ['HS256'],
      issuer: 'https://app.example.com',
      audience: 'app',
    });
    if (req.nextUrl.pathname.startsWith('/admin') && payload.role !== 'admin') {
      return NextResponse.redirect(new URL('/', req.url));
    }
    return NextResponse.next();
  } catch {
    return NextResponse.redirect(new URL('/login', req.url));
  }
}
Enter fullscreen mode Exit fullscreen mode

In a normal Node route handler, jsonwebtoken is fine, as long as it is 9.x and you pin the algorithm:

import jwt from 'jsonwebtoken'; // ^9.0.0

const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
Enter fullscreen mode Exit fullscreen mode

And in Python:

payload = jwt.decode(
    token,
    key=os.environ["JWT_SECRET"],
    algorithms=["HS256"],
    audience="app",
)
Enter fullscreen mode Exit fullscreen mode

Three details matter more than they look:

  • Fail loudly on a missing secret. The CVE-2022-23540 path needed a falsy key. Throwing at startup when the secret is unset removes that whole class of surprise.
  • Pin algorithms even where the library has safe defaults. It costs one line and it documents intent for the next person, or the next model, that edits the file.
  • Write the forged-token test. Take a valid token, change one claim, re-encode it, and assert you get a 401 or a redirect. It is the only test that can tell decode() and verify() apart, so it is the only one that will catch a regression.

One more check while you are in there: search the repo for decode( and verify_signature. If either appears in a file that makes an access decision, you have found this bug.

FAQ

Q: Is jwt.decode() ever safe to use?
A: Yes, for reading claims you are not going to trust, like showing a username in the UI or logging a token ID. It is never safe for deciding who someone is or what they can access.

Q: Why does jsonwebtoken fail in Next.js middleware?
A: It depends on Node's crypto module, which the Edge runtime does not provide. Use jose and its jwtVerify() function there instead of falling back to decode().

Q: Does jsonwebtoken 9 still accept alg none?
A: Not by default. Version 9.0.0 removed default support for none in verify(), and it now defaults the allowed algorithms based on the key type. Versions 8.5.1 and below are the risky ones.

I've been running SafeWeave inside Cursor and Claude Code as an MCP server so security checks happen while the code is still fresh in my head, not three days later in CI. But for this particular bug, the control that matters most is cheap and tool-agnostic: one forged-token test in your suite, and a grep for decode( in anything that guards a route. Catch it early, whatever you use.

Top comments (0)