OWASP Top 10: The Vulnerabilities That Keep Coming Back
The OWASP Top 10 is a list of the most critical web application security risks, maintained by the Open Worldwide Application Security Project and updated every few years. The same categories appear on the list decade after decade - not because the security community isn’t talking about them, but because developers keep building the same vulnerable patterns without realizing it.
Most security writing presents these as a numbered list and goes through them one by one. This article organizes them differently: by root cause. Because once you understand why a vulnerability class exists, you can recognize it in contexts you’ve never seen before - not just the named examples on the list.
Three causes produce the majority of web application vulnerabilities.
Cause 1: Untrusted Input Treated as Code
The most persistent vulnerability class in software history. When user-supplied data ends up in a context that interprets it as an instruction - a database query, an HTML document, an XML parser, a shell command - the user can control what the instruction does.
SQL injection is the textbook case. The fix is separating data from code, which parameterized queries do by design:
# Vulnerable - string concatenation builds SQL from user input
query = "SELECT * FROM users WHERE email = '" + email + "'"
# Safe - parameterized query keeps data and code separate
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
With parameterized queries, the database receives the SQL structure and the parameters separately. There is no text interpolation that could be exploited. The structure of the query can never change based on user input.
The same principle appears wherever data and instructions mix. XSS (Cross-Site Scripting) happens when user-supplied text ends up in HTML without escaping, allowing attackers to inject JavaScript that runs in other users’ browsers:
<!-- If 'name' comes from user input, this is vulnerable -->
<p>Welcome back, {{ name }}</p>
Modern frameworks (React, Vue, Angular) escape output by default, which is why XSS is less common in greenfield apps. It still appears in legacy code with manual HTML construction, anywhere raw HTML is intentionally rendered (rich text editors), and DOM-based XSS where JavaScript reads URL parameters and writes them to the page without escaping.
XXE (XML External Entities) is the less obvious variant. XML parsers support entity references to external resources by default. An attacker submitting XML to your API can use this to read files from your server or make requests to internal services:
<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<request><data>&xxe;</data></request>
The fix is disabling external entity resolution in your parser - one line of configuration that most teams don’t know to add because they don’t know the feature exists:
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True)
The common thread: treat all input from outside your system as untrusted data, never as trusted code. Use parameterized queries, framework-level output escaping, and conservative parser configurations. Sanitization as a fallback; isolation by design.
Cause 2: Authorization That Doesn’t Check Enough
Authorization failures are the most common OWASP category in recent rankings. The reason is structural: it’s easy to check whether someone is logged in, and easy to forget to check whether they’re allowed to do the specific thing they’re doing.
Broken access control (IDOR - Insecure Direct Object Reference) is the canonical form. The authenticated user makes a request for a resource that belongs to someone else:
# Checks authentication, not authorization
@app.route('/orders/<order_id>')
@login_required
def get_order(order_id):
return Order.get(order_id) # returns any order to any logged-in user
# Correct - ownership check on every request
@app.route('/orders/<order_id>')
@login_required
def get_order(order_id):
order = Order.get(order_id)
if order.user_id != current_user.id:
abort(403)
return order
Changing /orders/1001 to /orders/1002 should return a 403, not the next customer’s order. This feels obvious when stated plainly. In practice, it’s missed because developers test with their own account and never try someone else’s.
Other common failure modes: frontend doesn’t show the “delete” button, but the delete endpoint accepts requests from anyone who knows the URL. Admin functionality that checks the user’s role in the UI but not in the API handler. Privilege escalation through parameter manipulation.
Authentication gaps are the other side of this: authentication that can be bypassed or worn down.
- No brute force protection on login endpoints (credential stuffing works by trying millions of leaked username/password pairs)
- Passwords stored with fast hashing (MD5, SHA-256) - fast hashing is wrong for passwords because it makes brute force cheap
import bcrypt
# bcrypt is deliberately slow - that's the security feature
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
bcrypt.checkpw(password.encode(), hashed)
- Predictable, non-expiring, or URL-embedded session tokens
- No invalidation on logout
The common thread: authentication verifies who you are; authorization verifies what you’re allowed to do. Both need to be checked, explicitly, on every request. “The user is logged in” is not the same as “the user can access this resource.” Make authorization a deliberate operation, not a side effect of routing.
Cause 3: Insecure by Default
Systems that are more exposed than their operators know. This covers configuration that ships insecure, data that’s stored or transmitted without adequate protection, and features that are on when they should be off.
Security misconfiguration is the broadest category on the OWASP list. The consistent pattern: something is running that the team didn’t know about, or something is set to a default that made sense in development but not in production.
Common examples: default credentials never changed (admin/admin still works). Stack traces with internal file paths returned to the browser. Cloud storage buckets left publicly readable. An admin panel on a well-known path that nobody set up but also never disabled. Debug endpoints that expose internal metrics. Test accounts from a development setup still active in production.
A security review of almost any deployed system finds configuration the team didn’t know about. Infrastructure as code (every configuration version-controlled and reviewed) helps catch drift. Deployment checklists with a hardening step - turn off everything you don’t need - help catch defaults.
Sensitive data exposure is misconfiguration applied specifically to data protection: transmitting over HTTP instead of HTTPS, logging request bodies that contain passwords or tokens, caching responses that contain PII in CDN edges or browser caches, retaining full credit card numbers when only the last four digits are needed for display.
HTTPS is not optional. For data at rest, encryption is only as strong as its key management - encrypting the database but storing the keys adjacent to it provides limited protection. The most effective strategy is often minimization: don’t store sensitive data you don’t need. Use a payment processor that handles card data and never let raw card numbers touch your application.
The common thread: assume default is insecure. Audit what’s running. Minimize what you store. Build security hardening into the deployment process, not as an afterthought.
These three causes - untrusted input as code, insufficient authorization, and insecure defaults - account for most of what appears on the OWASP list and most of what appears in breach post-mortems. They’re not exotic attack vectors. They’re what happens when teams build systems without thinking through trust boundaries.
The mitigations are well-understood and mostly available in standard libraries. Parameterized queries, framework-level escaping, explicit authorization checks, bcrypt, HTTPS, least-privilege configuration. The work is knowing to use them - and building habits that make the insecure pattern harder to write than the secure one.