Most web applications contain at least one vulnerability from the OWASP Top 10. A typical security audit takes 2-3 weeks and costs upward of $10,000. An LLM can compress the initial audit down to a few hours because it scans code for patterns rather than specific CVEs.
Below are 15 vulnerabilities found while auditing production code with Claude. Each includes the vulnerable code, the fixed version, and a prompt to reproduce the finding. Classification follows OWASP Top 10 (2021). Order reflects frequency of occurrence: most common first.
The audit consists of three passes. First, a broad scan: the LLM receives the entire project and looks for vulnerability patterns. Second, deep analysis: each identified pattern is verified in context (middleware, ORM, framework). Third, verification: manual review of every finding, because LLMs produce false positives.
Prompt for the broad scan:
Perform a security audit of this code. For each finding, include:
1. CWE ID and name
2. OWASP Top 10 category
3. Severity (Critical/High/Medium/Low)
4. The vulnerable code snippet
5. Attack vector -- exactly how an attacker would exploit this
6. Fixed code
Ignore stylistic comments. Focus on security only.
Start with injection attacks, then broken access control, then the rest.
This prompt works because it defines the output structure and prioritizes categories. Without explicit instructions, the LLM mixes critical vulnerabilities with remarks about email validation.
More on structured AI code review: AI Code Review Checklist.
The most common finding. Shows up even in projects using an ORM, because developers switch to raw queries for complex filters.
Vulnerable code:
// API endpoint for user search
app.get('/api/users', async (req, res) => {
const { search, sortBy } = req.query;
const query = `
SELECT id, name, email
FROM users
WHERE name LIKE '%${search}%'
ORDER BY ${sortBy}
`;
const result = await db.query(query);
res.json(result.rows);
});
Attack vector: GET /api/users?search='; DROP TABLE users; --&sortBy=id
Fixed code:
app.get('/api/users', async (req, res) => {
const { search, sortBy } = req.query;
const allowedSortColumns = ['id', 'name', 'email', 'created_at'];
const sanitizedSort = allowedSortColumns.includes(sortBy) ? sortBy : 'id';
const query = `
SELECT id, name, email
FROM users
WHERE name LIKE $1
ORDER BY ${sanitizedSort}
`;
const result = await db.query(query, [`%${search}%`]);
res.json(result.rows);
});
Parameterized query for values, whitelist for identifiers (column names). ORDER BY
cannot be parameterized in most drivers, so the whitelist is mandatory.
// Vulnerable: req.body passed directly into the query
app.post('/api/login', async (req, res) => {
const user = await db.collection('users').findOne({
username: req.body.username,
password: req.body.password,
});
if (user) return res.json({ token: generateToken(user) });
res.status(401).json({ error: 'Invalid credentials' });
});
Attack vector: POST /api/login
with body {"username": "admin", "password": {"$ne": ""}}
. The $ne
(not equal) operator turns the password check into "password is not equal to empty string" -- true for any user.
// Fixed: explicit string casting
app.post('/api/login', async (req, res) => {
const username = String(req.body.username);
const password = String(req.body.password);
const user = await db.collection('users').findOne({ username });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({ token: generateToken(user) });
});
Two fixes: String()
blocks MongoDB operators, and bcrypt.compare
replaces plaintext password comparison.
// Vulnerable: user input in a shell command
app.post('/api/convert', async (req, res) => {
const { filename } = req.body;
exec(`convert uploads/${filename} -resize 200x200 thumbnails/${filename}`,
(err, stdout) => {
if (err) return res.status(500).json({ error: 'Conversion failed' });
res.json({ status: 'ok' });
});
});
Attack vector: filename: "image.png; rm -rf /"
// Fixed: execFile instead of exec, filename validation
import { execFile } from 'child_process';
app.post('/api/convert', async (req, res) => {
const { filename } = req.body;
if (!/^[a-zA-Z0-9_-]+\.(png|jpg|webp)$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
execFile('convert', [
`uploads/${filename}`, '-resize', '200x200', `thumbnails/${filename}`
], (err) => {
if (err) return res.status(500).json({ error: 'Conversion failed' });
res.json({ status: 'ok' });
});
});
execFile
does not spawn a shell, so ; rm -rf /
is not interpreted as a separate command. The regex restricts allowable characters.
// Vulnerable: any authenticated user can view any order
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
const order = await db.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);
res.json(order.rows[0]);
});
Attack vector: ID enumeration -- GET /api/orders/1
, /api/orders/2
, /api/orders/3
...
// Fixed: ownership check
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
const order = await db.query(
'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!order.rows[0]) return res.status(404).json({ error: 'Not found' });
res.json(order.rows[0]);
});
AND user_id = $2
added. If a role legitimately allows access to others' orders (admin, support), the check happens through RBAC middleware rather than through the absence of a condition.
@app.route('/api/files/<filename>')
def get_file(filename):
return send_file(f'uploads/{filename}')
Attack vector: GET /api/files/../../etc/passwd
import os
from werkzeug.utils import secure_filename
@app.route('/api/files/<filename>')
def get_file(filename):
safe_name = secure_filename(filename)
if not safe_name:
abort(400)
upload_dir = os.path.abspath('uploads')
file_path = os.path.abspath(os.path.join(upload_dir, safe_name))
if not file_path.startswith(upload_dir):
abort(403)
return send_from_directory(upload_dir, safe_name)
secure_filename
strips ../
, send_from_directory
confines the base directory, and the abspath
check prevents bypass via symlinks.
// Vulnerable: entire req.body passed to update
app.put('/api/profile', authMiddleware, async (req, res) => {
await db.query(
'UPDATE users SET name = $1, email = $2, role = $3 WHERE id = $4',
[req.body.name, req.body.email, req.body.role, req.user.id]
);
res.json({ status: 'updated' });
});
Attack vector: PUT /api/profile
with body {"name": "Hacker", "role": "admin"}
. User elevates their own role.
// Fixed: whitelist of allowed fields
app.put('/api/profile', authMiddleware, async (req, res) => {
const { name, email } = req.body;
await db.query(
'UPDATE users SET name = $1, email = $2 WHERE id = $3',
[name, email, req.user.id]
);
res.json({ status: 'updated' });
});
Destructuring picks only allowed fields. The role
field from the request is silently ignored.
// Vulnerable: algorithm taken from the token header
const decoded = jwt.verify(token, publicKey);
Attack vector: the attacker crafts a JWT with "alg": "none"
or "alg": "HS256"
, signing it with a symmetric key equal to the server's public key. Some libraries accept such tokens.
// Fixed: algorithm pinned explicitly
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
});
The algorithms
parameter prevents the server from accepting JWTs with an arbitrary algorithm. issuer
and audience
restrict the token's scope.
STRIPE_SECRET_KEY = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"
AWS_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"
DATABASE_URL = "postgresql://admin:password123@db.example.com/prod"
python
import os
def get_required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
STRIPE_SECRET_KEY = get_required_env("STRIPE_SECRET_KEY")
AWS_ACCESS_KEY = get_required_env("AWS_ACCESS_KEY")
DATABASE_URL = get_required_env("DATABASE_URL")
get_required_env
fails at startup if the variable is not set, preventing the service from silently starting without credentials and returning errors on every request.
Prompt for finding hardcoded secrets:
Find all hardcoded secrets in the codebase: API keys, passwords,
tokens, connection strings. Check: .env files committed to git;
string literals containing "sk_", "AKIA", "password", "secret";
config files with credentials. For each finding, include the file,
line number, and recommendation.
// Vulnerable: any site can make requests to the API
app.use(cors({ origin: '*', credentials: true }));
Attack vector: an attacker hosts JavaScript on their site that makes requests to the API on behalf of an authenticated user (cookies are sent automatically).
// Fixed: origin whitelist
const allowedOrigins = [
'https://example.com',
'https://app.example.com',
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
}));
!origin
passes requests without an Origin header (server-to-server). For production APIs, consider removing this check and always requiring Origin.
// Vulnerable: stack trace leaks to the client
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack,
query: err.query, // SQL query in the error
});
});
Attack vector: the error exposes database structure, file paths, and library versions, making targeted attacks easier.
// Fixed: log internally, send minimal info to client
app.use((err, req, res, next) => {
const errorId = crypto.randomUUID();
logger.error({
errorId,
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
userId: req.user?.id,
});
res.status(500).json({
error: 'Internal server error',
errorId,
});
});
errorId
links the client response to the log entry. The user reports the ID to support; the developer finds the full stack trace.
// Vulnerable: server makes a request to a user-supplied URL
app.post('/api/preview', async (req, res) => {
const { url } = req.body;
const response = await fetch(url);
const html = await response.text();
const title = extractTitle(html);
res.json({ title, url });
});
Attack vector: url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
-- access to the AWS metadata API from the internal network.
import { URL } from 'url';
import dns from 'dns/promises';
async function isAllowedUrl(input: string): Promise<boolean> {
const parsed = new URL(input);
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
const addresses = await dns.resolve4(parsed.hostname);
const blocked = ['10.', '172.16.', '192.168.', '169.254.', '127.'];
return !addresses.some(ip => blocked.some(prefix => ip.startsWith(prefix)));
}
app.post('/api/preview', async (req, res) => {
const { url } = req.body;
if (!await isAllowedUrl(url)) {
return res.status(400).json({ error: 'URL not allowed' });
}
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, {
signal: controller.signal,
redirect: 'error',
});
const html = await response.text();
res.json({ title: extractTitle(html), url });
});
DNS resolution verifies that the target IP does not belong to an internal network. redirect: 'error'
blocks redirects to internal addresses. The timeout prevents slowloris attacks.
// Vulnerable: check-then-act without locking
app.post('/api/withdraw', authMiddleware, async (req, res) => {
const { amount } = req.body;
const account = await db.query(
'SELECT balance FROM accounts WHERE user_id = $1', [req.user.id]
);
if (account.rows[0].balance >= amount) {
await db.query(
'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
[amount, req.user.id]
);
res.json({ status: 'ok' });
} else {
res.status(400).json({ error: 'Insufficient funds' });
}
});
Attack vector: two concurrent withdrawal requests. Both read balance 100, both pass the check, both deduct. Result: balance -100.
// Fixed: atomic operation in a transaction
app.post('/api/withdraw', authMiddleware, async (req, res) => {
const { amount } = req.body;
const client = await db.connect();
try {
await client.query('BEGIN');
const account = await client.query(
'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',
[req.user.id]
);
if (account.rows[0].balance < amount) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Insufficient funds' });
}
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
[amount, req.user.id]
);
await client.query('COMMIT');
res.json({ status: 'ok' });
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
});
FOR UPDATE
locks the row for the duration of the transaction. The second request waits for the first to finish and reads the updated balance.
// Vulnerable: regular string comparison
app.post('/api/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const expected = computeHmac(req.body, WEBHOOK_SECRET);
if (signature === expected) {
processWebhook(req.body);
res.json({ status: 'ok' });
} else {
res.status(401).json({ error: 'Invalid signature' });
}
});
Attack vector: the ===
operator exits at the first mismatched byte. By measuring response time, an attacker can recover the signature byte by byte.
import crypto from 'crypto';
app.post('/api/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const expected = computeHmac(req.body, WEBHOOK_SECRET);
if (!signature || !crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
processWebhook(req.body);
res.json({ status: 'ok' });
});
timingSafeEqual
compares all bytes in constant time, regardless of where the first mismatch occurs.
// Vulnerable: unlimited password brute force
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await findUserByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({ token: generateToken(user) });
});
python
// Fixed: rate limiting + account lockout
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: (req) => req.body.email || req.ip,
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
});
app.post('/api/login', loginLimiter, async (req, res) => {
const { email, password } = req.body;
const user = await findUserByEmail(email);
if (user?.lockedUntil && user.lockedUntil > new Date()) {
return res.status(423).json({ error: 'Account temporarily locked' });
}
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
if (user) {
await incrementFailedAttempts(user.id);
}
return res.status(401).json({ error: 'Invalid credentials' });
}
await resetFailedAttempts(user.id);
res.json({ token: generateToken(user) });
});
Rate limiting by email prevents brute-forcing a single account. Account lockout adds a second layer of defense. keyGenerator
uses email rather than just IP, so a distributed attack from multiple IPs is also blocked.
More on resilience patterns for API protection: Circuit Breaker in Deno Edge Functions.
// Vulnerable: recursive merge without protection
function deepMerge(target: any, source: any): any {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = deepMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
app.put('/api/settings', authMiddleware, async (req, res) => {
const currentSettings = await getSettings(req.user.id);
const merged = deepMerge(currentSettings, req.body);
await saveSettings(req.user.id, merged);
res.json(merged);
});
Attack vector: PUT /api/settings
with body {"__proto__": {"isAdmin": true}}
. After the merge, every object in the application inherits isAdmin: true
.
function deepMerge(target: any, source: any): any {
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
target[key] = deepMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
Three keys are blocked: __proto__
, constructor
, prototype
. In production, prefer battle-tested libraries: lodash merge
starting from 4.17.21 is protected, or use structuredClone
for copying.
A broad scan catches obvious vulnerabilities. Deep analysis requires specialized prompts by category.
Injection (A03):
Find all places where user input reaches SQL, NoSQL,
LDAP, OS commands, or ORM raw queries without parameterization.
Consider: query params, request body, headers, cookies, file uploads.
Check ORM methods that use raw SQL.
Access Control (A01):
Review every API endpoint: is there a check that the current user
owns the requested resource? Find endpoints that verify authentication
but not authorization. Pay attention to admin endpoints, bulk
operations, export/download.
SSRF and Insecure Design (A04):
Find all places where the server makes HTTP requests to a URL from
user input. Check: is it possible to reach internal services
(metadata API, localhost, private networks)? Is there URL validation,
DNS rebinding protection, redirect restrictions?
Authentication (A07):
Review the authentication mechanism: password storage (bcrypt/argon2?),
JWT (algorithm pinned? refresh tokens present?), sessions (httpOnly?
secure? sameSite?), rate limiting on login/register/reset-password.
Find endpoints without authentication that should be protected.
Manual audit provides depth. Automated audit in CI provides consistency. Combining both closes most vulnerabilities before production.
name: AI Security Audit
on:
pull_request:
paths:
- 'src/**'
- 'api/**'
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run SAST
run: |
npx semgrep --config=p/owasp-top-ten src/
- name: Check dependencies
run: npm audit --audit-level=high
- name: Check secrets
run: npx gitleaks detect --source=. --no-git
Semgrep covers OWASP Top 10 with minimal false positives. npm audit
catches vulnerable dependencies. Gitleaks finds committed secrets. LLM audit runs separately, at pre-merge review.
Before each release:
*
FOR UPDATE
timingSafeEqual
===
Each item corresponds to one of the 15 vulnerabilities above. If any item fails, it maps to a concrete vulnerability with a known attack vector.
Need help with AI-powered security audits? I help startups build AI products and automate processes — belov.works.
No. LLMs excel at pattern recognition across large codebases and surface the majority of OWASP Top 10 issues in hours, but they produce false positives and miss logic-level flaws that require understanding business context. A manual security review by a specialist is still necessary for critical systems — AI compresses the preparation phase and handles the repeatable patterns, freeing the human reviewer for the nuanced findings.
In practice, Claude and GPT-4o produce comparable results for security audits when given a structured prompt. The model matters less than the prompt quality and the completeness of the code submitted for review. What consistently degrades results: sending partial snippets instead of full files, omitting framework and ORM context, and skipping the verification pass against false positives.
Finding and removing the secret from code is not enough — it remains in Git history. Rotate the exposed credential immediately, then use git filter-repo
(not the deprecated git filter-branch
) to purge it from all commits. After that, set up pre-commit hooks with Gitleaks or detect-secrets to prevent future commits. Treat any secret that touched a repository as compromised, regardless of how briefly.