Introduction#
The OWASP Top 10 is the most widely referenced list of critical web application security risks. Backend engineers are responsible for most of these vulnerabilities — they exist in code, not configuration. This post covers the Top 10 with concrete examples of vulnerable code and their fixes.
A01: Broken Access Control#
The most common vulnerability. Failing to enforce authorization on every request.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| # VULNERABLE: authorization check missing for sensitive operation
@app.get("/api/documents/{doc_id}")
async def get_document(doc_id: int, db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == doc_id).first()
return doc # returns any document regardless of who's asking
# SECURE: always verify the requesting user owns the resource
@app.get("/api/documents/{doc_id}")
async def get_document(
doc_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
doc = db.query(Document).filter(
Document.id == doc_id,
Document.owner_id == current_user.id # enforce ownership
).first()
if not doc:
raise HTTPException(status_code=404) # 404, not 403 (don't reveal existence)
return doc
|
A02: Cryptographic Failures#
Sensitive data exposed due to weak or missing encryption.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| import hashlib
import bcrypt
import secrets
# VULNERABLE: MD5 is broken, SHA1 is weak, plain SHA256 without salt is vulnerable to rainbow tables
def hash_password_wrong(password: str) -> str:
return hashlib.md5(password.encode()).hexdigest()
# VULNERABLE: SHA256 without salt
def hash_password_still_wrong(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
# SECURE: use bcrypt (or argon2, scrypt) — slow by design, includes salt
def hash_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)
# For tokens: use cryptographically secure random bytes
def generate_reset_token() -> str:
return secrets.token_urlsafe(32) # 256 bits of entropy
# Store sensitive data encrypted at rest
from cryptography.fernet import Fernet
KEY = Fernet.generate_key() # store in secrets manager, not code
fernet = Fernet(KEY)
def encrypt(data: str) -> bytes:
return fernet.encrypt(data.encode())
def decrypt(token: bytes) -> str:
return fernet.decrypt(token).decode()
|
A03: Injection#
SQL, command, LDAP, and other injection vulnerabilities.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| import subprocess
import sqlite3
# SQL INJECTION — VULNERABLE
def get_user_wrong(username: str):
conn = sqlite3.connect("app.db")
# Attacker sends: username = "admin' OR '1'='1"
query = f"SELECT * FROM users WHERE username = '{username}'"
return conn.execute(query).fetchone()
# SQL INJECTION — SECURE: parameterized queries
def get_user(username: str):
conn = sqlite3.connect("app.db")
return conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
# ORM also prevents injection:
def get_user_orm(username: str, db: Session):
return db.query(User).filter(User.username == username).first()
# COMMAND INJECTION — VULNERABLE
def ping_host_wrong(host: str) -> str:
result = subprocess.run(
f"ping -c 1 {host}", # attacker sends: "8.8.8.8; rm -rf /"
shell=True, capture_output=True, text=True
)
return result.stdout
# COMMAND INJECTION — SECURE: use list form, never shell=True with user input
def ping_host(host: str) -> str:
import re
if not re.match(r'^[a-zA-Z0-9.\-]+$', host):
raise ValueError("Invalid hostname")
result = subprocess.run(
["ping", "-c", "1", host], # list form, no shell interpretation
capture_output=True, text=True, timeout=10
)
return result.stdout
|
A04: Insecure Design#
Missing threat modeling, no security requirements, unsafe defaults.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| # VULNERABLE: password reset that reveals user existence
@app.post("/forgot-password")
async def forgot_password_wrong(email: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(status_code=404, detail="Email not found") # reveals existence
send_reset_email(user)
return {"message": "Reset email sent"}
# SECURE: always return the same response regardless of whether user exists
@app.post("/forgot-password")
async def forgot_password(email: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == email).first()
if user: # silently skip if user not found
token = secrets.token_urlsafe(32)
store_reset_token(user.id, token, expires_in=3600)
send_reset_email(user.email, token)
return {"message": "If that email is registered, you'll receive a reset link"}
|
A05: Security Misconfiguration#
Default credentials, verbose error messages, unnecessary features enabled.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import logging
logger = logging.getLogger(__name__)
app = FastAPI()
# VULNERABLE: exposes internal details in error responses
@app.exception_handler(Exception)
async def generic_exception_handler_wrong(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"error": str(exc), # exposes exception message
"traceback": traceback.format_exc(), # exposes internals
}
)
# SECURE: log internally, return generic message externally
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
logger.exception("Unhandled exception for %s %s", request.method, request.url)
return JSONResponse(
status_code=500,
content={"error": "An internal error occurred"}
)
# Security headers middleware
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = "default-src 'self'"
return response
|
A06: Vulnerable and Outdated Components#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # Check for known vulnerabilities in Python dependencies
pip install pip-audit
pip-audit
# Check for known vulnerabilities in Node.js
npm audit
npm audit fix
# Use dependabot or renovate for automated updates
# .github/dependabot.yml
# version: 2
# updates:
# - package-ecosystem: "pip"
# directory: "/"
# schedule:
# interval: "weekly"
|
A07: Identification and Authentication Failures#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| import time
from collections import defaultdict
# VULNERABLE: no brute force protection
@app.post("/login")
async def login_wrong(username: str, password: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == username).first()
if user and verify_password(password, user.password_hash):
return {"token": create_token(user.id)}
raise HTTPException(status_code=401, detail="Invalid credentials")
# Track failed login attempts
login_attempts: dict[str, list[float]] = defaultdict(list)
WINDOW_SECONDS = 300 # 5 minutes
MAX_ATTEMPTS = 5
def check_rate_limit(identifier: str) -> None:
now = time.time()
attempts = [t for t in login_attempts[identifier] if now - t < WINDOW_SECONDS]
login_attempts[identifier] = attempts
if len(attempts) >= MAX_ATTEMPTS:
raise HTTPException(
status_code=429,
detail="Too many failed attempts. Try again later.",
headers={"Retry-After": str(WINDOW_SECONDS)},
)
# SECURE: rate limit + consistent timing (prevent username enumeration)
@app.post("/login")
async def login(username: str, password: str, db: Session = Depends(get_db)):
check_rate_limit(username)
user = db.query(User).filter(User.username == username).first()
# Always call verify_password to prevent timing attacks
password_valid = user is not None and verify_password(password, user.password_hash)
if not password_valid:
login_attempts[username].append(time.time())
raise HTTPException(status_code=401, detail="Invalid credentials")
return {"token": create_token(user.id)}
|
A08: Software and Data Integrity Failures#
Deserialization of untrusted data.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| import pickle
import json
# VULNERABLE: pickle deserializes arbitrary Python objects — remote code execution
@app.post("/load-session")
async def load_session_wrong(data: bytes):
session = pickle.loads(data) # NEVER deserialize user-controlled pickle data
return session
# SECURE: use JSON or validated schemas
from pydantic import BaseModel
class SessionData(BaseModel):
user_id: int
role: str
expires_at: int
@app.post("/load-session")
async def load_session(data: dict):
session = SessionData(**data) # validates and type-checks
return session
|
A09: Security Logging and Monitoring Failures#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| import logging
import uuid
from datetime import datetime
security_logger = logging.getLogger("security")
def log_security_event(
event_type: str,
user_id: int | None,
ip_address: str,
details: dict,
):
security_logger.warning(
"security_event",
extra={
"event_id": str(uuid.uuid4()),
"event_type": event_type,
"user_id": user_id,
"ip_address": ip_address,
"timestamp": datetime.utcnow().isoformat(),
**details,
}
)
# Log security-relevant events
log_security_event("login_failure", None, request.client.host, {"username": username})
log_security_event("privilege_escalation_attempt", user.id, request.client.host, {})
log_security_event("mass_data_export", user.id, request.client.host, {"record_count": 50000})
|
A10: Server-Side Request Forgery (SSRF)#
Covered in depth in the dedicated SSRF post, but the key principle:
1
2
3
| # Never fetch URLs provided by users without strict validation
# Use allowlists, resolve DNS, check against private IP ranges
# See the dedicated SSRF Prevention post for full implementation
|
Security Checklist#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| Authentication and Authorization:
- [ ] All endpoints verify authentication
- [ ] All endpoints verify authorization (resource ownership)
- [ ] Passwords hashed with bcrypt/argon2
- [ ] Rate limiting on login and sensitive endpoints
Data Handling:
- [ ] SQL via parameterized queries or ORM only
- [ ] No shell=True with user-controlled input
- [ ] No pickle/eval on user-controlled data
- [ ] Input validated at API boundaries
Error Handling:
- [ ] Generic error messages in responses
- [ ] Detailed errors logged internally with correlation IDs
- [ ] Security events logged to tamper-resistant storage
Transport:
- [ ] TLS enforced (HSTS header set)
- [ ] Security headers set on all responses
- [ ] Sensitive data not logged or exposed in error messages
|
Conclusion#
Most OWASP Top 10 vulnerabilities are preventable with consistent engineering practices: parameterized queries, proper password hashing, enforced authorization on every request, input validation at boundaries, generic error responses, and security logging. Security is not a feature added at the end — it is built into every layer during development.