TL;DR
- AI editors produce syntactically clean code that is semantically insecure
- The same 4 vulnerability patterns show up across almost every AI-generated codebase
- They're all fixable in minutes, if you know what to look for
I scanned a production app last week. Clean TypeScript, well-organized folders, decent test coverage. The kind of repo that looks like a senior dev wrote it.
Also: a hardcoded Stripe API key on line 8 of config.ts. A SQL query built from raw string concatenation. An auth endpoint with zero rate limiting. And a password hash using MD5.
The whole thing was built with an AI editor. And none of this is unusual.
I've been scanning AI-generated codebases for a while now. The same patterns keep showing up, almost without exception. Not because the developers are bad. they're often quite good. It's because AI models learned from tutorial code, and tutorials never prioritize security. They prioritize clarity.
Here are the four things I find most consistently, with the actual code patterns.
1. Hardcoded Secrets (CWE-798)
This is the #1 find. It shows up in roughly 2 out of 3 repos.
The AI generates something like this:
const stripe = new Stripe('sk_live_4xKj9mNpQ2rT...');
const jwtSecret = 'my-super-secret-key-123';
It looks fine in isolation. It runs. Tests pass. Then it gets committed, pushed, and either leaked via a public repo or exposed through git history. The fix is one line. The real problem is that the AI has no reason to generate the safe version. It's optimizing for "code that works in a tutorial," and environment variables are setup friction it skips for you.
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const jwtSecret = process.env.JWT_SECRET;
Same result. No secret in your codebase.
2. SQL Injection via Template Literals (CWE-89)
This one consistently surprises developers because it looks modern and intentional.
// What the AI writes
const results = await db.query(
\`SELECT * FROM users WHERE email = '${userInput}'\`
);
Template literals feel like the "new way" of writing strings. They're perfectly fine in most contexts. In SQL queries, they're an open door. An attacker sends ' OR '1'='1 as the email input, and the query becomes SELECT * FROM users WHERE email = '' OR '1'='1' . Every record in the table gets returned. With the right payload, they can run DROP TABLE users.
The parameterized version is just as readable:
const results = await db.query(
'SELECT * FROM users WHERE email = $1',
[userInput]
);
The AI could write this. It just doesn't default to it.
3. Missing Auth on API Endpoints (CWE-862)
When you ask an AI to "create a user profile endpoint," it creates the endpoint. That's what you asked for. What you probably also wanted. auth middleware. you didn't specify, so it didn't add it.
// What gets generated
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});
This endpoint is wide open. Anyone who knows or can guess a user ID gets that user's full data. No session check, no token validation, nothing. The fix requires one middleware call and a role check:
app.get('/api/users/:id', authenticate, async (req, res) => {
if (req.user.id !== req.params.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await User.findById(req.params.id);
res.json(user);
});
The AI doesn't make this mistake because it's careless. It makes it because auth middleware is a project-specific detail that doesn't exist in the training data for a generic endpoint example.
4. Weak Password Hashing (CWE-328)
Less common than the others, but I still find it regularly. especially in Python backends.
# What the AI generates
import hashlib
def hash_password(password):
return hashlib.md5(password.encode()).hexdigest()
MD5 was deprecated for password storage around 2004. It's fast. which is exactly wrong for passwords. A modern GPU can crack MD5 hashes at billions per second. If your database leaks, every password becomes plaintext within hours.
# What it should be
import bcrypt
def hash_password(password):
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
Bcrypt is intentionally slow. That's the feature, not a bug. A work factor of 12 makes each comparison take ~250ms, which is completely fine for login but completely impractical for brute-force at scale.
The Underlying Problem
These aren't random bugs. They're systematic patterns.
AI editors learned from code written to explain concepts, not to ship safely. Tutorial code hardcodes secrets to keep examples short. It skips auth to keep focus on the feature being demonstrated. It uses MD5 because that's what a StackOverflow answer from 2012 uses, and that answer is still indexed and still in the training data.
When an AI writes code to be "syntactically correct and functional," it succeeds. But security is about what the code doesn't accept, doesn't expose, doesn't allow. That negative space is invisible in tutorials. So it's invisible in AI output.
The real fix isn't running a scanner after the fact in CI/CD. By then you've already committed, moved on, and lost the mental context to fix it cleanly. You need something that catches this at the moment the code is generated, inside the editor. That's the workflow change that actually sticks.
I've 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's in this post. The important thing is catching it early, whatever tool you use.
Top comments (0)