The OWASP Top 10, Part 2
Stage 3 · Auth, Identity & Security · B.U.I.L.D. letter: L
You shipped something beautiful with AI at your side. Now an attacker is reading your
package.json, hammering your login endpoint, and waiting for your server to fetch a URL they control. Part 2 of the OWASP Top 10 is where production apps quietly bleed — not through flashy zero-days, but through things every builder can fix this afternoon.
⚠️ The vibe trap
Vibe coding gets you to a working app fast — that's genuinely powerful and worth celebrating. The trap is stopping there. A working app that leaks data, gets hijacked, or goes dark because you logged nothing is not a finished product. The second half of the OWASP Top 10 targets the operational layer: your dependencies, your identity checks, your supply chain, your observability, and your server's willingness to fetch any URL someone hands it. None of these require deep security expertise to fix. They require knowing they exist.
📦 A6 · Vulnerable & Outdated Components
What it is: Your app is not just your code. It's every npm package, every transitive dependency, every abandoned library you installed two years ago and forgot about. Attackers read public CVE feeds. They know which package versions are exploitable before you do.
Consequence: A single outdated dependency (famously: lodash prototype pollution, log4j RCE, event-stream supply chain attack) can give an attacker full control of your server or your users' data — regardless of how carefully you wrote your own code.
Vulnerable pattern:
// package.json — pinned to nothing, reviewed never
{
"dependencies": {
"express": "^4.17.1",
"jsonwebtoken": "^8.5.1",
"axios": "*"
}
}
Running npm install months later silently upgrades to a version with a known vulnerability. The * on axios is especially dangerous — it accepts any version, including ones that haven't been released yet by a potential supply-chain attacker.
Fixed pattern:
# Run this regularly — in CI on every PR, and locally before every deploy
npm audit
# Fix automatically where safe
npm audit fix
# Review what audit can't auto-fix
npm audit fix --force # only after reading what changes
# Lock your dependency tree
npm ci # instead of npm install in production; uses package-lock.json exactly
// package.json — pin majors at minimum, review minors
{
"dependencies": {
"express": "~4.19.2",
"jsonwebtoken": "~9.0.2",
"axios": "~1.7.2"
}
}
Why it works: ~ pins the patch range within the minor version. Combined with npm audit in CI, you catch new CVEs before they reach production. Abandon deps with no maintainer activity in 2+ years — find an alternative or fork and own it.
🔑 A7 · Identification & Authentication Failures
What it is: Your login endpoint is a front door. If you don't rate-limit it, an attacker can try millions of passwords. If you don't invalidate sessions properly, they can hijack one. If you never prompt for MFA on sensitive actions, a stolen password is game over.
Consequence: Account takeover at scale. Credential-stuffing bots run 24/7 against every login endpoint on the internet. Without rate limiting, your users' accounts are cracked while you sleep.
Vulnerable pattern:
// routes/auth.js — no rate limiting, no lockout, no MFA
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Session ID issued without invalidating the previous one — session fixation
req.session.userId = user.id;
res.json({ success: true });
});
Fixed pattern:
// routes/auth.js — rate-limited, session regenerated, MFA-ready
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15-minute window
max: 10, // 10 attempts per IP per window
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
});
app.post('/login', loginLimiter, async (req, res) => {
const { email, password, totpCode } = req.body;
const user = await db.findUserByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// Same message for both "no user" and "wrong password" — no user enumeration
return res.status(401).json({ error: 'Invalid credentials' });
}
if (user.mfaEnabled) {
const valid = verifyTOTP(user.totpSecret, totpCode);
if (!valid) return res.status(401).json({ error: 'Invalid 2FA code' });
}
// Regenerate session ID after successful auth — defeats session fixation
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.userId = user.id;
res.json({ success: true });
});
});
Why it works: Rate limiting stops brute-force and credential-stuffing cold. session.regenerate() issues a new session ID after auth, so an attacker who obtained the pre-login session ID can't use it. MFA means a stolen password alone isn't enough.
🔗 A8 · Software & Data Integrity Failures
What it is: Your app trusts things it shouldn't: a webhook payload without verifying the signature, a deserialized object without checking its type, an auto-update mechanism that doesn't verify what it's installing.
Consequence: An attacker can forge a webhook to trigger privileged actions (e.g., "mark order as paid"), inject malicious objects that execute code during deserialization, or hijack your CI/CD pipeline to deploy backdoored code to production.
Vulnerable pattern:
// webhooks/stripe.js — accepting any payload claiming to be from Stripe
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const event = JSON.parse(req.body); // No signature check — anyone can POST this
if (event.type === 'payment_intent.succeeded') {
fulfillOrder(event.data.object.metadata.orderId); // Attacker controls orderId
}
res.sendStatus(200);
});
Fixed pattern:
// webhooks/stripe.js — signature verified before any business logic
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
// Stripe signs every webhook with your endpoint secret — verify it
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.warn('Webhook signature verification failed:', err.message);
return res.status(400).send('Invalid signature');
}
if (event.type === 'payment_intent.succeeded') {
fulfillOrder(event.data.object.metadata.orderId);
}
res.sendStatus(200);
});
Why it works: constructEvent cryptographically verifies that the payload came from Stripe and hasn't been tampered with. Treat every inbound webhook the same way — GitHub, Twilio, Shopify all provide signature headers. Never skip them.
📋 A9 · Security Logging & Monitoring Failures
What it is: If your app doesn't log security events, attacks happen and you never know. No logs means no incident response, no forensics, no way to tell your users what happened or when.
Consequence: Breaches go undetected for months (the industry average is 204 days). By the time you notice, the attacker has exfiltrated everything, and you have no evidence trail to reconstruct what they accessed.
Vulnerable pattern:
// Logging nothing — the most common "pattern" in hobby-to-production apps
app.post('/login', async (req, res) => {
const user = await db.findUserByEmail(req.body.email);
if (!user || !checkPassword(req.body.password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
// Who tried to log in? When? From where? Unknown forever.
}
req.session.userId = user.id;
res.json({ success: true });
// Successful login also not logged. Audit trail: nonexistent.
});
Fixed pattern:
// Structured security logging — what to capture, what to redact
import pino from 'pino';
const log = pino({ level: process.env.LOG_LEVEL || 'info' });
app.post('/login', loginLimiter, async (req, res) => {
const { email, password } = req.body;
const ip = req.ip;
const ua = req.headers['user-agent'];
const user = await db.findUserByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// Log the failure — NOT the attempted password
log.warn({ event: 'login_failed', email, ip, ua, ts: Date.now() },
'Failed login attempt');
return res.status(401).json({ error: 'Invalid credentials' });
}
// Log the success
log.info({ event: 'login_success', userId: user.id, ip, ua, ts: Date.now() },
'User authenticated');
req.session.regenerate(() => {
req.session.userId = user.id;
res.json({ success: true });
});
});
Log these events: failed logins, successful logins, password resets, privilege escalations, admin actions, access-denied errors (403s), rate-limit hits, webhook signature failures.
Never log these: passwords (attempted or real), full credit card numbers, SSNs, session tokens, API keys, raw request bodies from payment endpoints. Logs are often stored in less-secure places than your database — treat them accordingly.
Why it works: Structured logs (JSON with consistent fields) are queryable. Set up alerts for: >5 failed logins from the same IP in 10 minutes, any 403 from an authenticated admin, any login from a new country for a high-privilege account. Tools like Datadog, Logtail, or even a free Loki instance make this cheap.
🌐 A10 · Server-Side Request Forgery (SSRF)
What it is: Your server fetches a URL on behalf of a user ("preview this link", "import from URL", "fetch this avatar"). An attacker supplies a URL pointing to your internal network — http://169.254.169.254/latest/meta-data/ (AWS metadata API), http://localhost:6379 (Redis), http://10.0.0.1/admin. Your server fetches it from inside the firewall and hands the response back.
Consequence: Cloud credential theft (the AWS metadata endpoint returns IAM keys), internal service enumeration, reading secrets from services that assume they're only reachable internally. This is how several high-profile cloud breaches started.
Vulnerable pattern:
// routes/preview.js — fetches any URL the user provides
app.get('/preview', async (req, res) => {
const { url } = req.query;
// No validation — attacker passes http://169.254.169.254/latest/meta-data/iam/
const response = await fetch(url);
const content = await response.text();
res.json({ content });
});
Fixed pattern:
// routes/preview.js — SSRF-safe URL fetching
import { URL } from 'url';
import dns from 'dns/promises';
const BLOCKED_RANGES = [
/^127\./, // loopback
/^10\./, // RFC 1918
/^172\.(1[6-9]|2\d|3[01])\./, // RFC 1918
/^192\.168\./, // RFC 1918
/^169\.254\./, // link-local (AWS metadata)
/^::1$/, // IPv6 loopback
/^fc00:/, // IPv6 private
];
async function isSafeUrl(rawUrl) {
let parsed;
try {
parsed = new URL(rawUrl);
} catch {
return false;
}
// Only allow https (not http, ftp, file://, etc.)
if (parsed.protocol !== 'https:') return false;
// Resolve hostname to IP, then check against blocked ranges
let addresses;
try {
addresses = await dns.resolve4(parsed.hostname);
} catch {
return false;
}
for (const ip of addresses) {
if (BLOCKED_RANGES.some((re) => re.test(ip))) return false;
}
return true;
}
app.get('/preview', async (req, res) => {
const { url } = req.query;
if (!(await isSafeUrl(url))) {
return res.status(400).json({ error: 'URL not allowed' });
}
const response = await fetch(url, {
redirect: 'error', // Don't follow redirects that could bypass the check
signal: AbortSignal.timeout(5000),
});
res.json({ content: await response.text() });
});
Why it works: DNS resolution at validation time catches hostnames that resolve to internal IPs. Blocking redirects (redirect: 'error') stops a public URL from bouncing to an internal one after the check passes. Only allowing https: eliminates file://, ftp://, gopher://, and other schemes attackers use to probe internal infrastructure.
🛠️ Your mission
-
Run
npm auditin your project. Read every finding. Fix everything rated "high" or "critical" before continuing. Update yourpackage-lock.jsonand commit it. -
Add login rate limiting to your app's authentication endpoint using
express-rate-limit(or your framework's equivalent). Configure: 10 attempts per 15-minute window per IP. -
Add structured security logging to at least these three events: failed login, successful login, and any route that changes user permissions or roles. Use a structured logger (pino, winston) so each log entry is a JSON object with
event,userId(oremail),ip, andtsfields. -
Audit one URL-fetching route (if you have one). Apply the
isSafeUrlpattern above or remove the endpoint if it isn't required.
✅ You're done when…
- Your Security Audit Checklist (OWASP Top 10 items A6–A10) has a green checkmark or a tracked issue for every item reviewed against your codebase
-
npm auditexits with zero high/critical vulnerabilities and the result is captured in CI - Your login endpoint returns HTTP 429 after 10 consecutive failed attempts from the same IP, verified with a quick curl loop or Postman collection
- Your server logs include at least
login_failedandlogin_successevents with IP and timestamp — and you have confirmed those logs contain no plaintext passwords - Any URL-fetching endpoint either blocks private IP ranges or has been removed
➡️ Next: Secrets Management. Build It Right, Or Don't Build It At All. 🏛️