Complete Guide to OWASP Top 10 2025
Mitigate each risk in the OWASP Top 10 2025 list with practical code examples. Covers the new supply chain and exception-handling categories, plus the reordered classics.
The OWASP Top 10 is the standard awareness document for web application security — a consensus ranking of the most critical risks, rebuilt from real-world data every few years. The 2025 edition is the first update since 2021, and it’s more than a reshuffle: two categories are new, one was broadened into something bigger, and SSRF lost its standalone slot entirely.
The list itself is built from two sources: anonymized vulnerability data contributed by testing organizations (hundreds of thousands of real applications analyzed) and a community survey that fills in the risks the raw data underrepresents, like insecure design. So the ranking reflects what actually breaks in production, not what sounds scary on paper.
If you built your security checklist around the 2021 list, several of your priorities need renumbering before anything else.
What changed from 2021 to 2025
| 2025 rank | Category | What happened |
|---|---|---|
| A01 | Broken Access Control | Unchanged at #1 — SSRF was folded in here |
| A02 | Security Misconfiguration | Jumped from #5 to #2, driven by cloud and framework defaults |
| A03 | Software Supply Chain Failures | New — expands the old “Vulnerable Components” into the whole chain: dependencies, build systems, distribution |
| A04 | Cryptographic Failures | Moved down from #2 |
| A05 | Injection | Moved down from #3 |
| A06 | Insecure Design | Moved down from #4 |
| A07 | Authentication Failures | Same rank, shorter name (was “Identification and Authentication Failures”) |
| A08 | Software or Data Integrity Failures | Unchanged |
| A09 | Security Logging & Alerting Failures | Renamed — “Monitoring” became “Alerting”, because logs nobody acts on don’t count |
| A10 | Mishandling of Exceptional Conditions | New — error paths, fail-open defaults, unhandled edge cases |
The rest of this guide walks each 2025 category with vulnerable code and working mitigations in Python, JavaScript, and Docker.
One warning before you plan around the ranking: don’t treat A01-to-A10 as a priority order for your own backlog. The rank reflects prevalence across hundreds of thousands of applications. Your exposure depends on your stack and architecture. A team shipping a single-page app behind a WAF cares about injection differently than a team running twenty internal services. Use the list as a checklist for coverage, then order your fixes by what an attacker could actually reach.
A01: Broken Access Control
Broken access control held the #1 spot, and the data behind it’s brutal: the vast majority of applications tested show at least one CWE in this bucket. It covers two failure modes: horizontal escalation, where one user reads or modifies another user’s data, and vertical escalation, where a regular user reaches admin functions.
The vulnerable code
# VULNERABLE: No access control check
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
user = db.query(User).filter(User.id == user_id).first()
return user
# VULNERABLE: Trusting client-side role
@app.put("/api/users/{user_id}/role")
def update_role(user_id: int, role: str):
user = db.query(User).filter(User.id == user_id).first()
user.role = role # Anyone can set any role!
db.commit()
Mitigation
from fastapi import Depends, HTTPException
def get_current_user(token: str = Depends(oauth2_scheme)):
# Validate token and return user
user = decode_jwt(token)
if not user:
raise HTTPException(status_code=401, detail="Invalid token")
return user
def require_role(required_role: str):
def role_checker(current_user: User = Depends(get_current_user)):
if current_user.role != required_role:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return current_user
return role_checker
@app.get("/api/users/{user_id}")
def get_user(
user_id: int,
current_user: User = Depends(get_current_user)
):
# Horizontal access control: users can only access their own data
if current_user.id != user_id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="Access denied")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@app.put("/api/users/{user_id}/role")
def update_role(
user_id: int,
role: str,
current_user: User = Depends(require_role("admin"))
):
user = db.query(User).filter(User.id == user_id).first()
user.role = role
db.commit()
return {"status": "updated"}
JavaScript mitigation
// Express middleware for access control
function requireOwnership(paramName = 'userId') {
return (req, res, next) => {
const resourceId = parseInt(req.params[paramName]);
if (req.user.id !== resourceId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
next();
};
}
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Apply middleware
app.get('/api/users/:userId', authenticate, requireOwnership(), getUser);
app.put('/api/users/:userId/role', authenticate, requireRole('admin'), updateRole);
Where SSRF lives now
In 2021, Server-Side Request Forgery had its own A10 slot. The 2025 edition folded it into Broken Access Control, which is the right mental model anyway: an SSRF attack is your server accessing a resource it shouldn’t, on the attacker’s behalf. Same root cause (a missing authorization check) on a different actor.
# VULNERABLE: Fetching a user-supplied URL with no validation
import requests
def fetch_url(url: str) -> str:
response = requests.get(url)
return response.text
# url = "http://169.254.169.254/latest/meta-data/" (AWS metadata)
# url = "http://localhost:8080/admin"
# url = "http://10.0.0.1/internal-service"
import ipaddress
import socket
import requests
from urllib.parse import urlparse
class SSRFProtector:
BLOCKED_IP_RANGES = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"), # Link-local / cloud metadata
ipaddress.ip_network("127.0.0.0/8"), # Loopback
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("::1/128"), # IPv6 loopback
ipaddress.ip_network("fc00::/7"), # IPv6 ULA
]
ALLOWED_SCHEMES = {"http", "https"}
ALLOWED_PORTS = {80, 443, 8080, 8443}
def validate_url(self, url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in self.ALLOWED_SCHEMES:
return False
port = parsed.port or (443 if parsed.scheme == "https" else 80)
if port not in self.ALLOWED_PORTS:
return False
# Resolve the hostname — the DNS answer is what matters, not the string
try:
ips = socket.getaddrinfo(parsed.hostname, port)
for ip_info in ips:
ip = ipaddress.ip_address(ip_info[4][0])
for blocked in self.BLOCKED_IP_RANGES:
if ip in blocked:
return False
except socket.gaierror:
return False
return True
def safe_fetch(self, url: str, timeout: int = 10) -> str:
if not self.validate_url(url):
raise ValueError("URL validation failed")
# Redirects disabled: each hop would need re-validation anyway
response = requests.get(url, timeout=timeout, allow_redirects=False)
if response.status_code in (301, 302, 307, 308):
location = response.headers.get("Location", "")
if not self.validate_url(location):
raise ValueError("Redirect target validation failed")
return response.text
protector = SSRFProtector()
def fetch_url(url: str) -> str:
return protector.safe_fetch(url)
The subtle part is socket.getaddrinfo. Validating the hostname string is useless if evil.com resolves to 169.254.169.254. Check the resolved IPs, not the URL text. And revalidate every redirect target, because the first hop can point to a safe URL that 302s straight into your VPC.
A02: Security Misconfiguration
Misconfiguration jumped three places to #2, and the driver is easy to see: more of the attack surface now lives in configuration than in code. Framework defaults, open cloud storage, verbose error pages, default credentials, debug mode left on in production. None of these are bugs you wrote, and that’s exactly why they slip through reviews.
Mitigation
# Secure FastAPI configuration
from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.responses import JSONResponse
app = FastAPI(
docs_url=None, # Disable docs in production
redoc_url=None,
openapi_url=None
)
# Strict CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["https://stackpractices.com"], # Specific origins only
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
# HTTPS redirect
app.add_middleware(HTTPSRedirectMiddleware)
# Security headers middleware
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = "default-src 'self'"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
return response
# Generic error handler — no stack traces
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"}
)
Docker security configuration
# Secure Dockerfile
FROM python:3.12-slim AS base
# Run as non-root user
RUN useradd -m -u 1001 appuser
USER appuser
WORKDIR /app
# Copy only what's needed
COPY --chown=appuser:appuser requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser . .
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8000/health || exit 1
The pattern to internalize: every default is a decision someone made for you, and attackers know the defaults better than you do. Disable what you don’t use, lock down what you keep, and treat X-XSS-Protection as legacy: modern browsers removed it because it caused more harm than good, so dropping it from your header list is fine.
A03: Software Supply Chain Failures
This is the biggest structural change in the 2025 list. The old “Vulnerable and Outdated Components” asked a narrow question: are your dependencies patched? The new category asks a wider one: can you trust the code you didn’t write, and the pipeline that hands it to you? That covers compromised packages, typosquatting, malicious version bumps, tampered build systems, leaked CI secrets, and unsigned artifacts.
Each arrow is a trust boundary where code you didn’t write becomes code you ship.
Mitigation
# Python: check for known vulnerabilities
pip install pip-audit
pip-audit
# Node.js: check for vulnerabilities
npm audit
npm audit fix
# Java: check dependencies
./gradlew dependencyCheck
# Automated dependency scanning in CI/CD — .github/workflows/security.yml
name: Security Scanning
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python dependency check
run: |
pip install pip-audit
pip-audit --strict
- name: npm audit
run: |
npm audit --audit-level=high
- name: Trivy filesystem scan
run: |
trivy fs --severity HIGH,CRITICAL .
Scanning covers the “vulnerable” half. For the “failures” half (an attacker shipping you a malicious version rather than an outdated one), pin dependencies with lockfiles and hashes (pip install --require-hashes, npm ci instead of npm install), generate an SBOM so you can answer “are we affected?” in minutes instead of days, and sign or verify build artifacts before deploying them. Tools like sigstore/cosign make artifact verification practical even for small teams.
A04: Cryptographic Failures
This category moved down two places but the failure modes haven’t changed: weak or broken algorithms, keys hardcoded or committed to the repo, passwords stored in plaintext or hashed with MD5/SHA-1, and sensitive data in transit without TLS.
The vulnerable code
# VULNERABLE: Storing passwords in plaintext
def register_user(username, password):
user = User(username=username, password=password) # Plaintext!
db.add(user)
db.commit()
# VULNERABLE: Weak hashing
import hashlib
def hash_password(password):
return hashlib.md5(password.encode()).hexdigest() # MD5 is broken
# VULNERABLE: Hardcoded encryption key
ENCRYPTION_KEY = "my_secret_key_123"
Mitigation
import bcrypt
import os
import secrets
from cryptography.fernet import Fernet
# Secure password hashing with bcrypt
def hash_password(password: str) -> str:
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode(), salt).decode()
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
def register_user(username: str, password: str):
hashed = hash_password(password)
user = User(username=username, password_hash=hashed)
db.add(user)
db.commit()
# Encryption with environment-based keys
def get_encryption_key() -> bytes:
key = os.environ.get("ENCRYPTION_KEY")
if not key:
raise RuntimeError("ENCRYPTION_KEY not set")
return key.encode()
def encrypt_data(plaintext: str) -> str:
f = Fernet(get_encryption_key())
return f.encrypt(plaintext.encode()).decode()
def decrypt_data(ciphertext: str) -> str:
f = Fernet(get_encryption_key())
return f.decrypt(ciphertext.encode()).decode()
# Secure token generation
def generate_api_token() -> str:
return secrets.token_urlsafe(32) # 256-bit token
Two rules cover most of this category: never roll your own crypto, and never store a secret in source control. bcrypt for passwords, Fernet/AES-GCM for data at rest, TLS for data in transit, and a secrets manager or environment variables for keys. For key management itself, see the Complete Guide to Secrets Management.
A05: Injection
Injection fell to #5. Parameterized queries and ORMs pushed classic SQLi down, but it hasn’t gone away. The category covers SQL, NoSQL, command, and LDAP injection, and modern codebases keep finding new interpreters to feed untrusted data into: template engines, shell calls, even LLM prompts.
The vulnerable code
# VULNERABLE: SQL injection
def get_user_by_email(email):
query = f"SELECT * FROM users WHERE email = '{email}'"
return db.execute(query)
# Attack: email = "' OR 1=1 --" returns all users
# VULNERABLE: Command injection
import subprocess
def ping_host(host):
result = subprocess.run(f"ping -c 1 {host}", shell=True)
return result.stdout
# Attack: host = "localhost; rm -rf /"
Mitigation
# SAFE: Parameterized queries
def get_user_by_email(email: str):
query = "SELECT * FROM users WHERE email = :email"
return db.execute(query, {"email": email})
# SAFE: ORM with built-in parameterization
def get_user_by_email(email: str):
return db.query(User).filter(User.email == email).first()
# SAFE: Command execution without shell
import re
import subprocess
def ping_host(host: str):
if not re.match(r'^[a-zA-Z0-9.\-]+$', host):
raise ValueError("Invalid hostname")
result = subprocess.run(
["ping", "-c", "1", host],
capture_output=True,
text=True,
shell=False # Never shell=True with user input
)
return result.stdout
# SAFE: NoSQL injection prevention
def find_user(query_dict: dict):
sanitized = {}
for key, value in query_dict.items():
if isinstance(value, dict) and any(k.startswith('$') for k in value):
raise ValueError("Operators not allowed")
sanitized[key] = value
return db.users.find_one(sanitized)
The defense is always the same shape: keep data and code in separate channels. Parameterized queries for SQL, argument arrays instead of shell=True for subprocess, allowlists for anything that can’t be parameterized. The NoSQL example matters more than it looks. MongoDB operators like $where or $gt are injection vectors that classic SQLi filters never touch.
A06: Insecure Design
Insecure design is the only category you can’t patch: it’s about decisions made before code existed. A registration flow with no rate limit, an API that trusts a client-supplied role, password reset without email verification: these work exactly as designed, and that’s the problem.
Mitigation: design patterns that close the gap
# Design pattern: Rate limiting on the registration endpoint
from collections import defaultdict
from datetime import datetime, timedelta
class RegistrationRateLimiter:
def __init__(self):
self.attempts: dict[str, list[datetime]] = defaultdict(list)
self.max_attempts = 3
self.window_hours = 24
def can_register(self, ip: str, email: str) -> bool:
now = datetime.now()
cutoff = now - timedelta(hours=self.window_hours)
key = f"{ip}:{email}"
self.attempts[key] = [t for t in self.attempts[key] if t > cutoff]
if len(self.attempts[key]) >= self.max_attempts:
return False
self.attempts[key].append(now)
return True
# Design pattern: Secure defaults
class UserAccount:
def __init__(self, username: str, email: str):
self.username = username
self.email = email
self.is_verified = False # Default: unverified
self.is_active = False # Default: inactive until verified
self.mfa_enabled = False
self.failed_login_count = 0
self.locked_until = None
def verify_email(self, token: str) -> bool:
if self._validate_token(token):
self.is_verified = True
self.is_active = True
return True
return False
# Design pattern: Principle of least privilege
class Permission:
READ = "read"
WRITE = "write"
DELETE = "delete"
ADMIN = "admin"
class Role:
VIEWER = {Permission.READ}
EDITOR = {Permission.READ, Permission.WRITE}
ADMIN = {Permission.READ, Permission.WRITE, Permission.DELETE, Permission.ADMIN}
def check_permission(user_role: set, required: str) -> bool:
return required in user_role
The fix for insecure design isn’t a library: it’s threat modeling before you build. Ask what an attacker gains from each endpoint, design abuse cases alongside use cases, and default everything to the most restrictive state (is_active = False until proven otherwise). Retrofitting these decisions after launch costs ten times what designing them costs.
A07: Authentication Failures
Same rank as 2021, shorter name. The failures are familiar: weak passwords allowed, no rate limiting on login, session tokens that never expire, credential stuffing succeeding because there’s no lockout, and password reset flows that leak whether an email exists.
Mitigation
import secrets
from datetime import datetime, timedelta
import bcrypt
class AuthService:
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION = timedelta(minutes=15)
def __init__(self, db, email_service):
self.db = db
self.email_service = email_service
def login(self, username: str, password: str) -> dict:
user = self.db.query(User).filter(User.username == username).first()
# Always run bcrypt — prevents timing attacks that reveal valid usernames
if not user:
bcrypt.checkpw(password.encode(), b"$2b$12$dummyhash")
raise AuthError("Invalid credentials")
if user.locked_until and user.locked_until > datetime.now():
raise AuthError(f"Account locked until {user.locked_until}")
if not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
user.failed_login_count += 1
if user.failed_login_count >= self.MAX_LOGIN_ATTEMPTS:
user.locked_until = datetime.now() + self.LOCKOUT_DURATION
user.failed_login_count = 0
self.db.commit()
raise AuthError("Invalid credentials")
# Reset on successful login
user.failed_login_count = 0
user.locked_until = None
token = secrets.token_urlsafe(32)
user.session_token = token
user.session_expires = datetime.now() + timedelta(hours=1)
self.db.commit()
return {"token": token, "expires_in": 3600}
def logout(self, token: str):
user = self.db.query(User).filter(User.session_token == token).first()
if user:
user.session_token = None
user.session_expires = None
self.db.commit()
def initiate_password_reset(self, email: str):
user = self.db.query(User).filter(User.email == email).first()
if not user:
# Don't reveal whether the email exists
return
reset_token = secrets.token_urlsafe(32)
user.reset_token = reset_token
user.reset_expires = datetime.now() + timedelta(hours=1)
self.db.commit()
self.email_service.send(user.email, "Password Reset", f"Token: {reset_token}")
The timing-attack line is easy to miss: if the code skips bcrypt.checkpw when the user doesn’t exist, the response time tells an attacker which usernames are valid. Running the hash on a dummy value keeps the timing identical either way. For the broader picture (OAuth, JWT, sessions, MFA), see the Complete Guide to Authentication Patterns.
A08: Software or Data Integrity Failures
Integrity failures sit right next to supply chain failures but answer a different question. A03 asks “can you trust where your code comes from?” A08 asks “can you trust what you’re about to run?” Insecure deserialization, unsigned updates, and trusting webhook payloads or CI artifacts without verification all live here.
Mitigation
import hashlib
import hmac
import jwt
# Verify webhook signatures
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
# Constant-time comparison — == leaks timing information
return hmac.compare_digest(expected, signature)
# Verify downloaded artifacts
def verify_download(content: bytes, expected_sha256: str) -> bool:
actual = hashlib.sha256(content).hexdigest()
return hmac.compare_digest(actual, expected_sha256)
# JWT verification with pinned algorithm
def verify_jwt(token: str, public_key: str) -> dict:
try:
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"], # Pin the algorithm — never trust the token's "alg" header
issuer="https://auth.stackpractices.com",
audience="stackpractices-api"
)
return payload
except jwt.ExpiredSignatureError:
raise AuthError("Token expired")
except jwt.InvalidTokenError:
raise AuthError("Invalid token")
The classic mistake here is accepting the alg header from the JWT itself. That’s what enabled the algorithm-confusion attacks where an RS256 token gets re-signed as HS256 using the public key as the HMAC secret. Pin the algorithm server-side. Same idea everywhere else: signatures, checksums, and hmac.compare_digest instead of ==, because a plain string comparison leaks byte-by-byte timing.
A09: Security Logging & Alerting Failures
The rename from “Monitoring” to “Alerting” is the whole story. Most breaches sit in logs for months before anyone notices. The 2025 list’s point is that logging without alerting is just expensive storage. You need both the events and a path that wakes someone up when they matter.
Mitigation
import json
import logging
from datetime import datetime
class SecurityLogger:
def __init__(self):
self.logger = logging.getLogger("security")
handler = logging.FileHandler("/var/log/app/security.log")
handler.setFormatter(logging.Formatter('%(message)s'))
self.logger.addHandler(handler)
self.logger.setLevel(logging.INFO)
def log_auth_event(self, event_type: str, user_id: str, ip: str, success: bool):
self.logger.info(json.dumps({
"timestamp": datetime.now().isoformat(),
"event": event_type,
"user_id": user_id,
"ip": ip,
"success": success,
"type": "authentication"
}))
def log_access_denied(self, user_id: str, resource: str, ip: str):
self.logger.warning(json.dumps({
"timestamp": datetime.now().isoformat(),
"event": "access_denied",
"user_id": user_id,
"resource": resource,
"ip": ip,
"type": "authorization"
}))
def log_security_event(self, event: str, details: dict):
self.logger.warning(json.dumps({
"timestamp": datetime.now().isoformat(),
"event": event,
"details": details,
"type": "security"
}))
# Usage
sec_logger = SecurityLogger()
sec_logger.log_auth_event("login", user.id, request.client.host, True)
sec_logger.log_auth_event("login_failed", username, request.client.host, False)
sec_logger.log_access_denied(user.id, f"/api/users/{target_id}", request.client.host)
sec_logger.log_security_event("sql_injection_attempt", {
"ip": request.client.host,
"input": user_input[:200] # Truncate — don't let attackers fill your disk
})
Structured logs are the easy half. The alerting half is: count login_failed per IP and page someone at a threshold, alert on any access_denied to admin routes, and alarm when error rates spike. An attacker probing your app looks exactly like a sudden burst of 4xx. If you can only do one thing, wire failed-logins-per-IP to an alert; credential stuffing shows up there first.
A10: Mishandling of Exceptional Conditions
The second new category, and the one most teams haven’t heard of yet. It covers what happens when things go wrong: unhandled exceptions, error paths that skip security checks, fail-open defaults, stack traces leaked to users, and resource exhaustion when an attacker triggers errors deliberately. Exceptional conditions are where carefully written code lets its guard down.
The vulnerable code
# VULNERABLE: Fail-open on unexpected errors
def transfer_funds(from_id: int, to_id: int, amount: float):
try:
sender = db.query(Account).get(from_id)
if sender.balance < amount:
raise InsufficientFunds()
sender.balance -= amount
receiver = db.query(Account).get(to_id)
receiver.balance += amount
db.commit()
except Exception as e:
# Error swallowed — sender already debited, commit half-done?
logger.error(f"transfer failed: {e}")
return {"status": "ok"} # Client thinks it worked!
# VULNERABLE: Stack trace leaked to the client
@app.get("/api/order/{order_id}")
def get_order(order_id: str):
order = db.execute(f"SELECT * FROM orders WHERE id = '{order_id}'")
return order # ValueError/DBError bubbles up as a 500 with internals
Mitigation
class TransferError(Exception):
pass
def transfer_funds(from_id: int, to_id: int, amount: float) -> dict:
try:
with db.transaction(): # Atomic — rolls back on any failure
sender = db.query(Account).with_for_update().get(from_id)
if sender is None:
raise TransferError("sender not found")
if sender.balance < amount:
raise TransferError("insufficient funds")
sender.balance -= amount
receiver = db.query(Account).with_for_update().get(to_id)
if receiver is None:
raise TransferError("receiver not found")
receiver.balance += amount
except TransferError as e:
# Expected failure — safe to surface
raise HTTPException(status_code=422, detail=str(e))
# Unexpected exceptions propagate to the global handler → generic 500
return {"status": "ok"}
@app.get("/api/order/{order_id}")
def get_order(order_id: int): # int validation fails closed at the router
order = db.query(Order).get(order_id)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
The principle is fail closed, recover explicitly. A security check that raises should deny by default, not pass by accident, which is why the transfer above uses an atomic transaction instead of hoping the except does the right thing. Expected failures get typed exceptions and proper status codes; unexpected ones fall through to a generic 500 that logs internally and says nothing to the client. The earlier global exception handler in A02 is the safety net that makes this work.
Mistakes that cut across all ten
Some failure patterns show up in every category:
- Trusting client-side anything. Hidden form fields, roles in JWT claims, “admin” flags in localStorage: if the client sends it, treat it as attacker input.
- Security added after the fact. Bolting a WAF or an audit pass onto a design with no rate limits or ownership checks fixes the symptoms, not the design.
- Defaults shipped to production. Debug flags, open CORS, documented endpoints. Whatever the framework gives you by default was chosen for developer convenience, not your threat model.
- Compliance as a proxy for security. Passing a checklist audit once a year tells you almost nothing about your day-to-day exposure; the categories above are what real attackers actually use.
See Also
Frequently Asked Questions
What actually changed between OWASP Top 10 2021 and 2025?
Two categories are new: Software Supply Chain Failures (A03) and Mishandling of Exceptional Conditions (A10). "Vulnerable and Outdated Components" was absorbed into the broader supply chain category, and SSRF lost its own slot, folding into Broken Access Control. Security Misconfiguration jumped from #5 to #2, and Logging was renamed to Logging & Alerting to stress that monitoring nobody acts on is useless.
Is SSRF still a risk if it's not its own category anymore?
Yes, it moved. It didn't disappear. OWASP folded it into A01 because an SSRF exploit is an access-control failure at heart: your server requests a resource it shouldn't on the attacker's behalf. You still need URL validation, egress controls, and metadata-endpoint blocks. The protection code above still applies, it's just filed under a different letter.
How often should I check my application against the OWASP Top 10?
At least quarterly with automated tools (OWASP ZAP, Burp Suite), plus a manual penetration test annually and a review after major architecture changes. Wire SAST and DAST into CI so every pull request gets checked. The list is an awareness document, but it works as a minimum audit checklist.
What is the difference between SAST and DAST?
SAST analyzes source code without running it and catches patterns like SQL injection sinks and hardcoded secrets. DAST attacks the running application from outside and finds runtime issues like misconfigured headers or auth bypasses. You want both: SAST early in development, DAST against staging.
Should I use a WAF (Web Application Firewall)?
Yes, as a layer. Cloud WAFs (AWS WAF, Cloudflare, Azure Front Door) block commodity attacks like SQLi scanners before they reach your app. But a WAF is defense in depth, not a fix: it buys you time while you patch, it doesn't patch for you.
Which OWASP 2025 category do teams underestimate most?
In practice, it's the two new categories that get skipped. Supply chain because "run npm audit" feels done while lockfiles, SBOMs, and signed builds stay unimplemented. And Mishandling of Exceptional Conditions because error paths get almost no review attention, yet they're exactly where security checks silently fail open.
Do I need to rework my 2021 security checklist?
Renumber it, don't rebuild it. Most of the mitigation work stays valid. The access control, crypto, injection, and auth code doesn't care which letter OWASP assigns it. The real work is adding the two new categories to your review process: supply chain verification (lockfiles, SBOM, signed artifacts) and error-path review (fail-closed defaults, no swallowed exceptions).
Where do I start if I can only fix one thing?
Broken Access Control. It's #1 for the fourth list running because it's the most common and the most impactful. An attacker doesn't need exotic tooling to change a userId in a request. Audit every endpoint that touches user data for an ownership check, and put middleware-level authorization in front of anything admin-only.
Related Resources
Complete Guide to Authentication Patterns
Implement authentication in production. Covers JWT, OAuth2, session-based auth, passkeys, MFA, refresh tokens, token rotation, RBAC, ABAC, SSO with SAML and OpenID Connect, and secure logout patterns with practical code examples.
GuideComplete Guide to API Security
Secure your APIs end-to-end. Covers rate limiting, authentication, input validation, CORS, SQL injection prevention, API gateway patterns, request size limits, pagination security, mass assignment, versioning, audit logging, and API security testing with practical code examples.
GuideComplete Guide to Secrets Management
Manage application secrets securely in production. Covers HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, Doppler, secret rotation, environment variables, zero-downtime rotation, and secrets in CI/CD pipelines with practical code examples.
GuideComplete Guide to Supply Chain Security
Secure your software supply chain end-to-end. Covers SBOM generation, dependency scanning, Sigstore, SLSA framework, provenance attestation, package registries, typosquatting, dependency confusion, and CI/CD pipeline hardening with practical code examples.
GuideOWASP Top 10: Explained with Mitigations
A developer-focused guide to the OWASP Top 10 security risks: how each vulnerability works, real-world examples, and practical mitigations for web applications.