AI Security Audit Checklist: 15 Vulnerabilities Claude Found in Production Code
What is an AI security audit?
An AI security audit is the use of large language models to systematically identify vulnerabilities in production code by scanning for known attack patterns rather than specific CVEs. Unlike traditional security reviews that take 2-3 weeks and cost $10,000+, an LLM-assisted audit compresses initial triage to a few hours by running structured prompts against the full codebase. It covers categories from the OWASP Top 10, including injection attacks, broken access control, cryptographic failures, and prototype pollution.
TL;DR
- -LLMs compress initial security audits from 2-3 weeks to a few hours by scanning for OWASP Top 10 patterns rather than specific CVEs
- -SQL injection via string concatenation remains the most frequent finding, even in projects using ORMs — developers bypass them with raw queries for complex filters
- -A three-pass methodology works best: broad scan, deep contextual analysis per category, manual verification to filter false positives
- -JWT without algorithm pinning allows attackers to forge tokens with 'alg: none'; the fix is a single line adding the 'algorithms' parameter
- -CI automation with Semgrep, npm audit, and Gitleaks covers consistent SAST checks; the LLM review runs separately at pre-merge stage
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.
Methodology: how to run an AI security audit
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.
A03:2021 — Injection
1. SQL Injection via string concatenation
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.
2. NoSQL Injection in MongoDB queries
// 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.
3. Command Injection via child_process
// 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.
A01:2021 — Broken Access Control
4. IDOR — Insecure Direct Object Reference
// 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.
5. Path Traversal when serving files
# Vulnerable: user controls the file path
@app.route('/api/files/<filename>')
def get_file(filename):
return send_file(f'uploads/{filename}')
Attack vector: GET /api/files/../../etc/passwd
# Fixed: send_from_directory + validation
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.
6. Mass Assignment — overwriting fields via API
// 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.
A02:2021 — Cryptographic Failures
7. JWT without algorithm verification
// 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.
8. Secrets hardcoded in source code
# Vulnerable: keys in source code
STRIPE_SECRET_KEY = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"
AWS_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"
DATABASE_URL = "postgresql://admin:[email protected]/prod"
# Fixed: environment variables + startup validation
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.
A05:2021 — Security Misconfiguration
9. CORS — allowing all origins
// 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.
10. Verbose error messages in production
// 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.
A04:2021 — Insecure Design
11. SSRF — Server-Side Request Forgery
// 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.
12. Race Condition in payment processing
// 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.
A07:2021 — Identification and Authentication Failures
13. Timing Attack in token comparison
// 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.
14. No rate limiting on authentication
// 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) });
});
// 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.
A08:2021 — Software and Data Integrity Failures
15. Prototype Pollution via deep object merge
// 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.
Prompts for each OWASP category
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.
Automation: CI pipeline for security audit
Manual audit provides depth. Automated audit in CI provides consistency. Combining both closes most vulnerabilities before production.
# .github/workflows/security-audit.yml
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.
Quick audit checklist
Before each release:
- All user inputs are parameterized (SQL, NoSQL, shell)
- Every endpoint checks resource ownership, not just authentication
- File operations are confined to the base directory (path traversal)
- Update operations accept a whitelist of fields (mass assignment)
- JWT algorithm is pinned in code, not taken from the token
- No secrets in source code or build variables
- CORS origin is restricted to a whitelist, not
* - Error messages do not expose internals (stack trace, SQL)
- External URLs go through DNS validation (SSRF)
- Financial operations run in transactions with
FOR UPDATE - Token comparison uses
timingSafeEqual, not=== - Rate limiting on auth endpoints + account lockout
- Deep object merges are protected against prototype pollution
- CI pipeline includes SAST (semgrep) and dependency audit
- LLM security review on every PR touching API/auth
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.
FAQ
Can an LLM replace a professional penetration tester?
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.
Which model performs best for security auditing — GPT-4o, Claude, or Gemini?
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.
How do I handle secrets that were already committed to the repository?
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.