The OWASP Top 10 covers the 10 most critical web application security risks. A01 Broken Access Control and A03 Injection (SQL, NoSQL, OS commands) are responsible for the majority of disclosed breaches. Every category in this guide has a detectable code pattern. Running custodia scan . checks all 10 automatically — this guide explains what the scanner is looking for and how to read and fix each finding.
Manual code review is effective for architecture decisions and business logic. It is poor at systematically finding security patterns across thousands of files — because the human eye optimizes for functionality, not for the negative space of what's missing (an auth check, a parameterized query, a rate limit).
Automated static analysis closes this gap. It reads every file, traces every variable flow, and compares patterns against known vulnerability signatures. The sections below explain what each OWASP category looks like in code so you understand what automated tools are flagging — and can make informed decisions about fixes.
OWASP Top 10 (2021 edition). Every category includes the exact code pattern that gets flagged and the correct fix.
Users can act outside their intended permissions — viewing other users' data, accessing admin functions, or elevating privileges. The #1 OWASP category by prevalence. 94% of applications tested had some form of broken access control.
// ❌ IDOR — no ownership check
// User can access ANY report by guessing the ID
app.get('/api/reports/:id', async (req, res) => {
const report = await db.reports.findById(req.params.id);
// ^ No check that report.userId === req.user.id
return res.json(report);
});// ✅ Ownership enforced at query level
app.get('/api/reports/:id', requireAuth, async (req, res) => {
const report = await db.reports.findOne({
id: req.params.id,
userId: req.user.id, // ← enforced in query
});
if (!report) return res.status(404).json({ error: 'Not found' });
return res.json(report);
});Sensitive data exposed due to weak or absent encryption. Common patterns: passwords stored in plain text or MD5, HTTP instead of HTTPS, AES-ECB mode, hardcoded encryption keys, unencrypted PII in databases or logs.
// ❌ MD5 for password hashing — completely broken
import { createHash } from 'crypto';
async function hashPassword(password: string) {
return createHash('md5').update(password).digest('hex');
// MD5 is not a password hash — rainbow tables crack it instantly
}// ✅ bcrypt with appropriate cost factor
import bcrypt from 'bcrypt';
async function hashPassword(password: string) {
const SALT_ROUNDS = 12; // adjust to ~250ms on your hardware
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}User-supplied data is sent to an interpreter without separation from commands. SQL injection, NoSQL injection, OS command injection, LDAP injection. Still #3 despite being decades old — because developers keep concatenating strings.
// ❌ SQL Injection — classic string concatenation
const results = await db.query(
`SELECT * FROM users WHERE email = '${req.body.email}'`
// ^^^^^^^^^^^^^^^^
// Input: admin' OR '1'='1 → dumps entire users table
);// ✅ Parameterized query — input never touches SQL string
const results = await db.query(
'SELECT * FROM users WHERE email = $1',
[req.body.email] // ← safely bound, never interpreted
);
// ORMs handle this automatically:
const user = await prisma.user.findFirst({
where: { email: req.body.email }
});Architectural flaws that cannot be patched — they require redesign. Missing rate limiting on account recovery, password reset tokens that never expire, security questions instead of MFA, business logic that allows negative cart totals.
// ❌ Password reset token — no expiry, no single-use
const token = crypto.randomBytes(32).toString('hex');
await db.passwordResets.create({ userId, token });
// Token valid forever, can be reused repeatedly// ✅ Time-limited, single-use reset token
const token = crypto.randomBytes(32).toString('hex');
await db.passwordResets.create({
userId,
token,
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 min
used: false,
});
// On use:
const reset = await db.passwordResets.findOne({ token, used: false });
if (!reset || reset.expiresAt < new Date()) throw new Error('Invalid');
await db.passwordResets.update({ id: reset.id }, { used: true });Default credentials, enabled debug features, overly permissive CORS, verbose error messages exposing stack traces, missing security headers, open S3 buckets, default admin accounts.
// ❌ Overly permissive CORS + verbose errors
app.use(cors({ origin: '*' })); // Any site can call your API
app.use((err, req, res, next) => {
res.status(500).json({
error: err.stack, // exposes file paths, line numbers, internals
});
});// ✅ Allowlist CORS + sanitized errors
app.use(cors({
origin: ['https://custodia.dev', 'https://app.custodia.dev'],
credentials: true,
}));
app.use((err, req, res, next) => {
console.error(err); // log internally
res.status(500).json({ error: 'Internal server error' });
// No stack trace in response
});Using dependencies with known CVEs. Log4Shell, Heartbleed, and Polyfill.io supply chain attack all exploited this. Run dependency audits. Pin versions. Check npm advisory database automatically in CI.
# ✅ Regular dependency audit in CI
# package.json script:
"scripts": {
"audit:ci": "npm audit --audit-level=high"
}
# GitHub Actions step:
- name: Dependency audit
run: npm audit --audit-level=high
# Fails build if any high/critical CVEs foundWeak credential policies, missing MFA, flawed session management, predictable session tokens, JWT with alg:none, credential stuffing without rate limiting.
// ❌ JWT accepted with 'none' algorithm
import jwt from 'jsonwebtoken';
// Attacker can forge tokens with alg: none
app.use((req, res, next) => {
const decoded = jwt.decode(req.headers.authorization);
// ^^^ .decode() does NOT verify signature
req.user = decoded;
next();
});// ✅ Always verify JWT signature with secret
app.use((req, res, next) => {
try {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'], // never allow 'none'
});
req.user = decoded;
next();
} catch {
res.status(401).json({ error: 'Unauthorized' });
}
});Insecure deserialization, CI/CD pipeline integrity failures, auto-updates without signature verification, npm packages with malicious postinstall scripts, prototype pollution.
// ❌ Deserializing untrusted data
import serialize from 'node-serialize';
app.post('/restore', (req, res) => {
// Attacker can craft RCE payload in serialized object
const obj = serialize.unserialize(req.body.data);
});// ✅ Use JSON.parse — no code execution possible
app.post('/restore', (req, res) => {
try {
const obj = JSON.parse(req.body.data);
// Validate schema before using
const validated = RestoreSchema.parse(obj);
} catch {
return res.status(400).json({ error: 'Invalid data' });
}
});No audit trail for logins, access control failures, or high-value transactions. Without logging, breaches go undetected for months. The average breach dwell time is still 207 days.
// ❌ Auth failures silently discarded
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
if (!user) return res.status(401).json({ error: 'Invalid' });
// No log of failed attempt, no alerting, no rate tracking
});// ✅ Structured security event logging
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
if (!user) {
securityLog.warn({
event: 'auth.failure',
email: req.body.email,
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
});
return res.status(401).json({ error: 'Invalid credentials' });
}
securityLog.info({ event: 'auth.success', userId: user.id, ip: req.ip });
});Attacker tricks your server into making requests to internal services — cloud metadata endpoints (169.254.169.254), internal APIs, databases. Exploited in the Capital One breach (AWS metadata SSRF).
// ❌ SSRF — fetching arbitrary user-supplied URLs
app.post('/preview', async (req, res) => {
const response = await fetch(req.body.url);
// Attacker supplies: http://169.254.169.254/metadata/
// → AWS instance metadata, credentials dumped
return res.json({ content: await response.text() });
});// ✅ Allowlist + block private IP ranges
import { isPrivateIP } from './utils/network';
app.post('/preview', async (req, res) => {
const url = new URL(req.body.url);
// Only allow HTTPS to known domains
if (url.protocol !== 'https:') return res.status(400).end();
if (!ALLOWED_DOMAINS.includes(url.hostname)) return res.status(403).end();
if (isPrivateIP(url.hostname)) return res.status(403).end();
const response = await fetch(url.toString(), { redirect: 'error' });
return res.json({ content: await response.text() });
});Before any PR merge touching auth, data access, or external I/O — run through this list. Each item maps to a specific OWASP category.
The OWASP Top 10 is a standard awareness document for developers representing the most critical security risks to web applications, updated every 3 years based on real-world breach data. The 2021 edition is the current standard, topped by Broken Access Control, Cryptographic Failures, and Injection.
SQL injection occurs when user-controlled input is concatenated into SQL strings. Search for string interpolation near .query(), .execute(), or .raw() calls. Any `SELECT ... WHERE ... ${userInput}` pattern is a vulnerability. The fix is always parameterized queries — in Node.js, this means using the second argument to db.query() or using an ORM like Prisma that parameterizes by default.
Broken Access Control means users can act outside their intended permissions. Common patterns: IDOR (accessing /api/reports/123 without checking ownership), missing auth middleware on protected routes, privilege escalation via role manipulation in JWTs, and forced browsing to admin endpoints.
Reflected XSS is injected via a URL parameter and reflects back immediately. Stored XSS is persisted to a database and served to all subsequent users — making it much higher impact. Both are prevented by output encoding and avoiding dangerouslySetInnerHTML with unsanitized content.
Automated static analysis catches systematic patterns like raw SQL concatenation, hardcoded secrets, and missing authentication middleware. Manual review catches logical flaws like broken business logic and insecure design. The right approach uses automated scanning for pattern detection and reserves manual review for architecture and data flow decisions.
Free scan covers OWASP Top 10 + OWASP LLM Top 10 across your full codebase. No config. No agent needed.