Web Security for Developers — OWASP Top 10 Explained
Practical guide to each OWASP Top 10 vulnerability with real code examples showing the vulnerability AND the fix. Covers XSS, SQL injection, CSRF, broken auth, SSRF, and more.
Security isn't something you bolt on after launch. Every vulnerability in the OWASP Top 10 exists because a developer made a reasonable-seeming decision that turned out to be exploitable. The fix is almost always simple -- but only if you know what to look for.
This covers the 2021 OWASP Top 10 (still the current standard as of 2026) with vulnerable code alongside the fix for each. Every example is something you'd actually encounter in a production codebase.
A01: Broken Access Control
The most common vulnerability. The server trusts the client to only request resources it should have access to.
Vulnerable -- direct object reference without authorization:// Express route -- anyone can view any user's profile
app.get("/api/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user); // includes email, address, payment info
});
Fixed -- verify ownership:
app.get("/api/users/:id", authenticate, async (req, res) => {
// Only allow users to access their own data (or admins)
if (req.user.id !== req.params.id && req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
const user = await db.users.findById(req.params.id);
res.json(user);
});
Common broken access control patterns:
| Pattern | Risk | Fix |
|---|---|---|
/api/admin/users with no auth check | Full data exfiltration | Middleware auth on all admin routes |
Modifying user_id in POST body | Acting as another user | Use session user ID, ignore body |
/api/invoices/123 without ownership check | IDOR (Insecure Direct Object Reference) | WHERE clause includes user_id |
| Client-side role checks only | Bypass by calling API directly | Server-side role validation |
?role=admin in registration form | Privilege escalation | Ignore role from client input |
A02: Cryptographic Failures
Storing passwords in plaintext, using MD5/SHA1 for hashing, transmitting sensitive data over HTTP, or hardcoding encryption keys.
Vulnerable -- MD5 password hashing:import hashlib
def store_password(password: str) -> str:
return hashlib.md5(password.encode()).hexdigest() # Cracked in seconds
Fixed -- bcrypt with salt:
import bcrypt
def store_password(password: str) -> bytes:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
def verify_password(password: str, hashed: bytes) -> bool:
return bcrypt.checkpw(password.encode(), hashed)
Why bcrypt and not SHA-256? SHA-256 is a fast hash -- that's a problem. GPUs can compute billions of SHA-256 hashes per second. Bcrypt is intentionally slow (the rounds parameter controls cost), making brute force attacks impractical.
| Algorithm | Hashes/sec (GPU) | Time to crack 8-char password |
|---|---|---|
| MD5 | ~40 billion | Seconds |
| SHA-256 | ~4 billion | Minutes |
| bcrypt (cost 12) | ~10,000 | Centuries |
| Argon2id | ~1,000 | Heat death of the universe |
- Sensitive data in URLs (
/reset-password?token=abc123gets logged in server access logs) - API keys committed to git (use environment variables)
- JWT tokens with
"alg": "none"accepted by the server
A03: Injection
SQL injection is the classic, but injection applies to any context where user input gets interpreted as code: SQL, NoSQL, LDAP, OS commands, XPath.
Vulnerable -- string concatenation in SQL:// Express route
app.get("/api/search", async (req, res) => {
const query = req.query.q;
// NEVER DO THIS
const results = await db.query(SELECT * FROM products WHERE name LIKE '%${query}%');
res.json(results);
});
// Attack: ?q=' OR '1'='1' --
// Returns every row in the table
Fixed -- parameterized queries:
app.get("/api/search", async (req, res) => {
const query = req.query.q;
const results = await db.query(
"SELECT * FROM products WHERE name LIKE $1",
[%${query}%]
);
res.json(results);
});
With parameterized queries, the database treats the parameter as a literal string value, never as SQL syntax. The fix is that simple.
NoSQL injection (MongoDB):// Vulnerable -- user controls the query operator
app.post("/api/login", async (req, res) => {
const user = await db.users.findOne({
username: req.body.username,
password: req.body.password,
});
});
// Attack body: { "username": "admin", "password": { "$ne": "" } }
// Matches any non-empty password -- bypasses auth
Fixed -- validate types:
app.post("/api/login", async (req, res) => {
if (typeof req.body.username !== "string" || typeof req.body.password !== "string") {
return res.status(400).json({ error: "Invalid input" });
}
// Now safe to query
});
A04: Insecure Design
This is about architectural flaws, not implementation bugs. No amount of input validation fixes a fundamentally insecure design.
Examples:
- A password reset flow that emails the actual password (implies plaintext storage)
- A "security question" system (answers are often public knowledge or guessable)
- Rate-limiting login on the frontend but not the API
- Allowing unlimited OTP attempts (brute force a 6-digit code in minutes)
Vulnerable -- no rate limit on OTP verification:
@app.post("/verify-otp")
async def verify_otp(phone: str, code: str):
stored_code = redis.get(f"otp:{phone}")
if stored_code == code:
return {"status": "verified"}
return {"status": "invalid"}
# 6-digit OTP = 1 million combinations
# At 100 requests/sec, cracked in ~2.7 hours
Fixed -- limit attempts and add lockout:
@app.post("/verify-otp")
@rate_limit(max_requests=5, window_seconds=300)
async def verify_otp(phone: str, code: str):
attempts_key = f"otp_attempts:{phone}"
attempts = int(redis.get(attempts_key) or 0)
if attempts >= 5:
return {"status": "locked", "message": "Too many attempts. Request a new code."}
redis.incr(attempts_key)
redis.expire(attempts_key, 300)
stored_code = redis.get(f"otp:{phone}")
if stored_code == code:
redis.delete(attempts_key)
return {"status": "verified"}
return {"status": "invalid", "remaining_attempts": 4 - attempts}
A05: Security Misconfiguration
Default credentials, unnecessary features enabled, overly verbose error messages, missing security headers.
Essential security headers (Express):import helmet from "helmet";
app.use(helmet()); // Sets ~15 security headers with sane defaults
// Or manually:
app.use((req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-XSS-Protection", "0"); // Deprecated, CSP is better
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
next();
});
Verbose error messages leak information:
// BAD -- tells attackers your stack
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack,
query: err.sql, // Exposes database schema
});
});
// GOOD -- generic message, log details server-side
app.use((err, req, res, next) => {
console.error(err); // Full details in server logs only
res.status(500).json({ error: "Internal server error" });
});
A06: Vulnerable and Outdated Components
Using dependencies with known vulnerabilities. This is the one most teams ignore because updating is tedious.
# Check for known vulnerabilities
npm audit
# Check for outdated packages
npm outdated
# Automated fixes (be careful -- test after)
npm audit fix
Set up automated dependency scanning. GitHub's Dependabot, Snyk, or Socket.dev will flag vulnerable packages in PRs. The CodeUp engineering blog on codeup.dev has covered dependency management strategies in more depth.
A07: Identification and Authentication Failures
Weak passwords allowed, credential stuffing not prevented, session tokens in URLs, sessions that never expire.
Vulnerable -- no session expiry, no rotation:app.post("/login", async (req, res) => {
const user = await authenticate(req.body);
if (user) {
req.session.userId = user.id;
// Session lives forever, token never changes
res.json({ success: true });
}
});
Fixed -- session configuration:
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JavaScript can't access
secure: true, // HTTPS only
sameSite: "strict", // CSRF protection
maxAge: 30 60 1000, // 30 minutes
},
}));
app.post("/login", async (req, res) => {
const user = await authenticate(req.body);
if (user) {
req.session.regenerate((err) => { // New session ID post-login
req.session.userId = user.id;
res.json({ success: true });
});
}
});
Session regeneration after login prevents session fixation attacks, where an attacker sets a known session ID before the victim authenticates.
A08: Software and Data Integrity Failures
Trusting data from untrusted sources without verification. Insecure deserialization, unsigned updates, CI/CD pipeline compromise.
Vulnerable -- deserializing untrusted data (Python):import pickle
# NEVER unpickle data from users
@app.post("/import")
async def import_data(file: UploadFile):
data = pickle.loads(await file.read()) # Arbitrary code execution
return {"imported": len(data)}
Fixed -- use safe formats:
import json
@app.post("/import")
async def import_data(file: UploadFile):
try:
data = json.loads(await file.read())
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON")
# Validate structure with Pydantic
validated = [ImportItem(**item) for item in data]
return {"imported": len(validated)}
Also relevant: verifying integrity of downloaded files, using lockfiles (package-lock.json, poetry.lock) to prevent supply chain attacks, and enabling npm's --ignore-scripts for untrusted packages.
A09: Security Logging and Monitoring Failures
If you can't detect an attack, you can't respond to it. Most breaches go undetected for months.
What to log:// Authentication events
logger.info("login_success", { userId: user.id, ip: req.ip });
logger.warn("login_failure", { username: req.body.username, ip: req.ip });
logger.warn("login_lockout", { username: req.body.username, ip: req.ip, attempts: count });
// Authorization failures
logger.warn("access_denied", { userId: req.user.id, resource: req.path, ip: req.ip });
// Input validation failures (potential probing)
logger.warn("invalid_input", { field: "email", value: "[redacted]", ip: req.ip });
// Rate limit triggers
logger.warn("rate_limit_exceeded", { ip: req.ip, endpoint: req.path });
What NOT to log:
- Passwords (even failed ones)
- Full credit card numbers
- Session tokens
- PII beyond what's needed for investigation
A10: Server-Side Request Forgery (SSRF)
The server makes HTTP requests on behalf of the user, and the user controls the URL. This lets attackers reach internal services that aren't exposed to the internet.
Vulnerable -- user-controlled URL fetch:app.get("/api/preview", async (req, res) => {
const url = req.query.url;
const response = await fetch(url); // User controls this entirely
const html = await response.text();
res.json({ preview: extractTitle(html) });
});
// Attack: ?url=http://169.254.169.254/latest/meta-data/
// Returns AWS instance metadata including IAM credentials
Fixed -- URL validation and allowlist:
import { URL } from "url";
import dns from "dns/promises";
const BLOCKED_RANGES = ["10.", "172.16.", "192.168.", "169.254.", "127.", "0."];
async function isUrlSafe(urlString) {
let parsed;
try {
parsed = new URL(urlString);
} catch {
return false;
}
// Only allow HTTP(S)
if (!["http:", "https:"].includes(parsed.protocol)) return false;
// Resolve hostname to check for internal IPs
const { address } = await dns.lookup(parsed.hostname);
if (BLOCKED_RANGES.some((range) => address.startsWith(range))) return false;
return true;
}
app.get("/api/preview", async (req, res) => {
const url = req.query.url;
if (!(await isUrlSafe(url))) {
return res.status(400).json({ error: "URL not allowed" });
}
const response = await fetch(url, { redirect: "error" }); // Don't follow redirects
const html = await response.text();
res.json({ preview: extractTitle(html) });
});
The redirect: "error" is important -- without it, an attacker can use a redirect to bounce from an allowed external URL to an internal one, bypassing the check.
Security Checklist for Every Project
| Area | Check | Priority |
|---|---|---|
| Authentication | Bcrypt/Argon2, session rotation, rate limiting | Critical |
| Authorization | Server-side checks on every endpoint | Critical |
| Input | Parameterized queries, type validation | Critical |
| Headers | CSP, HSTS, X-Frame-Options via Helmet | High |
| Dependencies | npm audit, automated scanning | High |
| Logging | Auth events, access denials, anomaly alerts | High |
| HTTPS | Enforce everywhere, HSTS preload | High |
| Cookies | httpOnly, secure, sameSite | Medium |
| Errors | Generic user messages, detailed server logs | Medium |
| SSRF | URL validation, IP range blocking | Medium |