TL;DR
- Cursor generates
jwt.decodeinstead ofjwt.verify, which skips signature validation and lets anyone forge a token - Hardcoded
'secret'strings appear in most AI-generated JWT auth code — the model learned it from tutorials - Three changes fix the worst of it: use
jwt.verify, pull the secret fromprocess.env, addexpiresIn: '15m'
I have been reviewing side projects and startup MVPs built with Cursor and Claude Code for the last few months. The auth code patterns are remarkably consistent. Not consistent in a good way.
The specific issue I keep hitting: JWT implementation. Almost every codebase I look at has at least one of three problems. Sometimes all three in the same file. And the pattern is so uniform it is clearly a training data problem, not a developer knowledge problem.
The Code Cursor Generates
Here is a condensed but representative example of what comes out of Cursor when you ask it to scaffold a login endpoint:
const jwt = require('jsonwebtoken');
app.post('/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (user && await bcrypt.compare(req.body.password, user.password)) {
// CWE-798: hardcoded secret, no expiry
const token = jwt.sign({ userId: user._id }, 'secret');
res.json({ token });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
app.get('/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
// CWE-347: jwt.decode skips signature verification entirely
const decoded = jwt.decode(token);
res.json({ user: decoded });
});
Three problems in about 20 lines.
1. jwt.decode instead of jwt.verify (CWE-347)
jwt.decode base64-decodes the payload. That is all it does. It does not check the signature. So anyone can take a valid JWT, swap the userId for a different one, and your server will accept it as legitimate. This is a complete authentication bypass. The endpoint trusts whatever is in the token without verifying it was signed by you.
2. Hardcoded 'secret' (CWE-798)
This specific string shows up in training data from thousands of JWT tutorials, every "getting started" blog post, and half the Stack Overflow answers from 2017-2022. When Cursor suggests it, it is not being careless. It is reproducing what it learned. But a hardcoded secret means every deployed instance shares the same signing key, it lives in your repo history, and it is trivially brute-forced.
3. No token expiry
Without expiresIn, a stolen token is valid forever. It does not matter if you implement refresh token rotation or logout endpoints later. The original token stays valid regardless.
Why This Pattern Keeps Showing Up
This is the part that took me a while to internalize. The problem is not that AI is bad at security in some fundamental way. The problem is that it was trained on a decade of content where the goal was to demonstrate a feature quickly, not to demonstrate it securely.
The jwt.decode pattern is everywhere in introductory JWT tutorials. The hardcoded 'secret' string is in basically every code snippet written for illustration rather than production. When a model trains on that corpus, it learns those patterns. And it reproduces them confidently, because they are statistically dominant.
When you prompt Cursor for "add JWT auth to my Express app", it is not reasoning about security. It is completing a pattern it has seen thousands of times. The pattern it has seen thousands of times is the insecure one.
The Fix
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) throw new Error('JWT_SECRET environment variable required');
app.post('/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (user && await bcrypt.compare(req.body.password, user.password)) {
const token = jwt.sign(
{ userId: user._id },
JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ token });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
app.get('/profile', (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET);
res.json({ user: decoded });
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
});
Three changes: jwt.verify instead of jwt.decode, secret pulled from an environment variable, token expiry set to 15 minutes. That's the core of it.
Generate a proper secret with openssl rand -base64 32 and add it to your .env file. Do not commit .env to git. If you already have a hardcoded secret in your repo history, rotate it immediately on the JWT provider or auth service side.
For the endpoint itself, notice the try-catch on jwt.verify. When a token is expired, tampered with, or simply invalid, verify throws. Without the catch, your server throws an unhandled exception. With it, you return a clean 401.
I have been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and flags these patterns before I move on. That said, even a basic pre-commit hook with semgrep and gitleaks will catch most of what is in this post. The important thing is catching it early, whatever tool you use.
Top comments (0)