August 12, 2026
The OWASP Top 10 for Developers Who Don’t Have Time to Read the OWASP Top 10
A plain-language tour of the ten ways your app most likely gets owned — and the small code changes that stop them.

By Marcelo Domingues
6 min read
Nobody got breached because they didn't read a 90-page PDF. They got breached because a string got concatenated into a SQL query at 4:55 PM on a Friday. The OWASP Top 10 is the industry's best-known list of how web apps actually get compromised, but the official material reads like a compliance document, and most of us are shipping features, not studying taxonomies.
So here's the deal: I'm going to walk you through all ten categories in the language you actually use at standup, and for the ones that bite developers most often I'll show you the vulnerable code next to the fixed code. No theory you can't act on. By the end you'll know what each category means and exactly what to type to avoid it.
A 30-Second Mental Model
The OWASP Top 10 (current 2021 edition; a 2025 update is in progress) isn't a list of specific bugs — it's a list of categories of bugs, ranked roughly by how common and how damaging they are. Think of it as the ten neighborhoods where vulnerabilities like to live.
Now let's make each one real.
A01: Broken Access Control
This is number one for a reason — it's everywhere. Authentication answers "who are you?" Authorization answers "are you allowed to do this?" Broken access control means your code checks the first and forgets the second.
The classic is the IDOR (Insecure Direct Object Reference): a user changes an ID in the URL and sees someone else's data.
# VULNERABLE — returns any invoice by id, no ownership check
@app.get("/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
invoice = db.invoices.find_one({"id": invoice_id})
return jsonify(invoice)
# FIXED — scope the query to the authenticated user
@app.get("/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
invoice = db.invoices.find_one({
"id": invoice_id,
"owner_id": current_user.id, # ownership enforced in the query
})
if invoice is None:
abort(404) # don't leak existence with a 403
return jsonify(invoice)# VULNERABLE — returns any invoice by id, no ownership check
@app.get("/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
invoice = db.invoices.find_one({"id": invoice_id})
return jsonify(invoice)
# FIXED — scope the query to the authenticated user
@app.get("/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
invoice = db.invoices.find_one({
"id": invoice_id,
"owner_id": current_user.id, # ownership enforced in the query
})
if invoice is None:
abort(404) # don't leak existence with a 403
return jsonify(invoice)The rules that prevent 90% of access-control bugs:
- Deny by default. Every route requires an explicit allow.
- Enforce ownership in the data layer, not just the UI. Hiding a button is not security.
- Never trust client-supplied roles or IDs to make authorization decisions.
A02: Cryptographic Failures
Formerly "Sensitive Data Exposure." The point: data that should be protected — passwords, tokens, PII — gets exposed because crypto was missing, weak, or misused.
The single most common offense is storing passwords wrong. Do not use MD5, SHA-1, or even plain SHA-256 for passwords. Use a slow, salted password hash.
# VULNERABLE — fast, unsalted hash; trivially cracked offline
import hashlib
def store_password(pw):
return hashlib.sha256(pw.encode()).hexdigest()
# FIXED — bcrypt: salted and deliberately slow
import bcrypt
def store_password(pw: str) -> bytes:
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt(rounds=12))
def verify_password(pw: str, stored: bytes) -> bool:
return bcrypt.checkpw(pw.encode(), stored)# VULNERABLE — fast, unsalted hash; trivially cracked offline
import hashlib
def store_password(pw):
return hashlib.sha256(pw.encode()).hexdigest()
# FIXED — bcrypt: salted and deliberately slow
import bcrypt
def store_password(pw: str) -> bytes:
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt(rounds=12))
def verify_password(pw: str, stored: bytes) -> bool:
return bcrypt.checkpw(pw.encode(), stored)Beyond passwords: encrypt data in transit (TLS everywhere), encrypt sensitive data at rest, and never roll your own crypto. Use the vetted primitive (libsodium, your cloud KMS, cryptography's Fernet) instead of cobbling together AES modes you found on Stack Overflow.
A03: Injection
Injection happens when untrusted input is interpreted as code. SQL injection is the poster child, but it applies to NoSQL, OS commands, LDAP, and more. The root cause is always the same: you mixed data and code in the same string.
# VULNERABLE — SQL injection via string formatting
def find_user(username):
query = f"SELECT * FROM users WHERE name = '{username}'"
return db.execute(query)
# FIXED — parameterized query; the driver keeps data and code separate
def find_user(username):
query = "SELECT * FROM users WHERE name = %s"
return db.execute(query, (username,))# VULNERABLE — SQL injection via string formatting
def find_user(username):
query = f"SELECT * FROM users WHERE name = '{username}'"
return db.execute(query)
# FIXED — parameterized query; the driver keeps data and code separate
def find_user(username):
query = "SELECT * FROM users WHERE name = %s"
return db.execute(query, (username,))The fix is parameterized queries (a.k.a. prepared statements) — always. Use your ORM's bound parameters. For OS commands, pass arguments as a list instead of a shell string:
# VULNERABLE — shell interprets the input
os.system("ping " + host)
# FIXED — no shell, arguments passed as a list
import subprocess
subprocess.run(["ping", "-c", "1", host], check=True)# VULNERABLE — shell interprets the input
os.system("ping " + host)
# FIXED — no shell, arguments passed as a list
import subprocess
subprocess.run(["ping", "-c", "1", host], check=True)Cross-site scripting (XSS) is injection into the browser. Let your template engine auto-escape output, and never innerHTML untrusted data.
A04: Insecure Design
This category is about flaws you can't patch with a one-line fix because the design is wrong. Example: a "forgot password" flow that lets you reset any account by guessing a 4-digit code with no rate limit. The code is technically correct; the design is the vulnerability.
The defensive move is threat modeling lite before you build: for each feature, ask "how could this be abused?" Add limits, secure defaults, and abuse-case handling at design time. You can't refactor your way out of a fundamentally insecure flow.
A05: Security Misconfiguration
The vulnerabilities of forgetting. Debug mode in production. Default admin/admin credentials. An S3 bucket set to public. Verbose stack traces handed to attackers. Unnecessary features and ports left open.
# VULNERABLE — Flask debug mode exposes an interactive console in prod
app.run(host="0.0.0.0", debug=True)
# FIXED — debug controlled by env, off by default; behind a real WSGI server
app.run(host="0.0.0.0", debug=os.environ.get("FLASK_DEBUG") == "1")# VULNERABLE — Flask debug mode exposes an interactive console in prod
app.run(host="0.0.0.0", debug=True)
# FIXED — debug controlled by env, off by default; behind a real WSGI server
app.run(host="0.0.0.0", debug=os.environ.get("FLASK_DEBUG") == "1")Checklist: disable debug in prod, change every default credential, lock down cloud storage, strip verbose errors from responses, and harden HTTP headers (CSP, X-Content-Type-Options, HSTS).
A06: Vulnerable and Outdated Components
You are not just running your code — you're running thousands of lines from your dependency tree, and some of those have published CVEs. The Log4Shell incident was this category writ large.
You don't have to memorize CVEs. You have to automate the check:
# Node
npm audit --audit-level=high
# Python
pip-audit
# Anything, in CI
# (GitHub Dependabot / Renovate open PRs automatically when fixes land)# Node
npm audit --audit-level=high
# Python
pip-audit
# Anything, in CI
# (GitHub Dependabot / Renovate open PRs automatically when fixes land)Pin versions, scan dependencies in CI, fail the build on high-severity findings, and treat upgrades as routine maintenance rather than a once-a-year fire drill.
A07: Identification and Authentication Failures
Weak login. Permitting password123, no protection against credential stuffing, session tokens that never expire, or predictable session IDs. Multi-factor authentication is the highest-leverage defense here.
Practical defaults:
- Enforce strong passwords by checking against known-breached lists, not by demanding a symbol and a capital letter.
- Rate-limit and lock out after repeated failures.
- Offer (and encourage) MFA.
- Regenerate the session ID on login and expire idle sessions.
# FIXED — rotate session on privilege change to prevent session fixation
@app.post("/login")
def login():
if not verify_credentials(request.form):
abort(401)
session.clear() # drop any pre-login session
session["user_id"] = user.id # new authenticated session
session.permanent = True
return redirect("/dashboard")# FIXED — rotate session on privilege change to prevent session fixation
@app.post("/login")
def login():
if not verify_credentials(request.form):
abort(401)
session.clear() # drop any pre-login session
session["user_id"] = user.id # new authenticated session
session.permanent = True
return redirect("/dashboard")A08: Software and Data Integrity Failures
This is about trusting code or data whose integrity you haven't verified. Two common forms: insecure deserialization (loading attacker-controlled serialized objects that execute code) and compromised build pipelines (pulling an unsigned plugin or a poisoned dependency).
# VULNERABLE — pickle can execute arbitrary code on load
import pickle
data = pickle.loads(untrusted_bytes)
# FIXED — use a data-only format for untrusted input
import json
data = json.loads(untrusted_bytes) # parses data, not code# VULNERABLE — pickle can execute arbitrary code on load
import pickle
data = pickle.loads(untrusted_bytes)
# FIXED — use a data-only format for untrusted input
import json
data = json.loads(untrusted_bytes) # parses data, not codeVerify checksums and signatures on artifacts, use lockfiles with integrity hashes, and don't deserialize untrusted input with formats that can carry code (pickle, Java serialization, PHP unserialize).
A09: Security Logging and Monitoring Failures
If an attacker is inside and no alarm fires, you'll learn about the breach from a journalist. The fix is unglamorous but vital: log security-relevant events (logins, failures, access-control denials, high-value actions) and actually alert on them.
# Log auth failures with context — but never log the password itself
logger.warning(
"auth_failure",
extra={"username": username, "ip": request.remote_addr},
)# Log auth failures with context — but never log the password itself
logger.warning(
"auth_failure",
extra={"username": username, "ip": request.remote_addr},
)Log enough to investigate, never log secrets or full PII, centralize logs, and set alerts on anomalies like a spike in 401s or access-denied events.
A10: Server-Side Request Forgery (SSRF)
SSRF is when your server fetches a URL supplied by the user, and an attacker points that URL at something internal — your cloud metadata endpoint, an internal admin panel, localhost. The server becomes a confused deputy.
# VULNERABLE — fetches whatever URL the user provides
def fetch_preview(url):
return requests.get(url).text
# FIXED — allowlist hosts and block internal address ranges
import ipaddress, socket
from urllib.parse import urlparse
ALLOWED_HOSTS = {"images.example.com", "cdn.example.com"}
def fetch_preview(url: str) -> str:
host = urlparse(url).hostname
if host not in ALLOWED_HOSTS:
raise ValueError("host not allowed")
ip = ipaddress.ip_address(socket.gethostbyname(host))
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise ValueError("internal address blocked")
return requests.get(url, timeout=5, allow_redirects=False).text# VULNERABLE — fetches whatever URL the user provides
def fetch_preview(url):
return requests.get(url).text
# FIXED — allowlist hosts and block internal address ranges
import ipaddress, socket
from urllib.parse import urlparse
ALLOWED_HOSTS = {"images.example.com", "cdn.example.com"}
def fetch_preview(url: str) -> str:
host = urlparse(url).hostname
if host not in ALLOWED_HOSTS:
raise ValueError("host not allowed")
ip = ipaddress.ip_address(socket.gethostbyname(host))
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise ValueError("internal address blocked")
return requests.get(url, timeout=5, allow_redirects=False).textPrefer an allowlist of destinations, block private and link-local IP ranges, and disable redirects so a permitted host can't bounce you somewhere internal.
How to Actually Use This
You will not fix all ten categories this afternoon, and you don't need to. Here's the order I'd tackle them in if I inherited an unfamiliar codebase:
- Access control (A01) — grep for every endpoint and confirm ownership checks.
- Injection (A03) — find string-built queries; parameterize them.
- Dependencies (A06) — turn on automated scanning today; it's free.
- Secrets and crypto (A02) — verify password hashing and TLS.
- Everything else, as you touch the code.
The Top 10 is a map of where the monsters live, not a homework assignment. Internalize the five-minute version above, wire dependency and secret scanning into CI so the boring stuff happens automatically, and review new code with one question in your head: what happens if this input is hostile? That mindset catches more bugs than any checklist.
If this saved you a few hours of PDF-reading, follow me for more no-nonsense application security write-ups. Got a war story about an IDOR you found in production — or a category you'd rank differently? Drop it in the comments; I read every one and the best ones end up shaping the next article.