OWASP Top 10: Web Application Security Risks
The Open Web Application Security Project (OWASP) maintains the industry's most referenced list of web application security risks. Updated roughly every three to four years, the Top 10 represents a...
Key Insights
- The OWASP Top 10 represents the most critical web application security risks, with Broken Access Control rising to the #1 position in 2021—affecting 94% of tested applications.
- Most vulnerabilities stem from preventable mistakes: missing authorization checks, unsanitized inputs, outdated dependencies, and poor configuration management.
- Security isn’t a feature you bolt on later; it requires threat modeling during design, automated scanning in CI/CD, and a culture where every developer understands common attack vectors.
Introduction to OWASP Top 10
The Open Web Application Security Project (OWASP) maintains the industry’s most referenced list of web application security risks. Updated roughly every three to four years, the Top 10 represents a consensus view of the most critical vulnerabilities based on real-world data from security firms, bug bounty programs, and penetration testing results.
The 2021 update brought significant changes. Broken Access Control jumped from fifth to first place. Three new categories emerged: Insecure Design, Software and Data Integrity Failures, and Server-Side Request Forgery. These shifts reflect how attack surfaces have evolved—modern applications face threats that didn’t exist a decade ago.
Understanding the Top 10 isn’t optional for professional developers. These vulnerabilities appear in compliance requirements (PCI-DSS, SOC 2), security audits, and job interviews. More importantly, they represent the attack vectors most likely to compromise your application. Let’s examine each category with practical code examples and fixes.
Broken Access Control (#1)
Broken Access Control affects 94% of applications tested by OWASP contributors. This vulnerability occurs when users can act outside their intended permissions—accessing other users’ data, modifying records they shouldn’t touch, or escalating privileges.
Horizontal privilege escalation happens when User A accesses User B’s resources. Vertical privilege escalation occurs when a regular user gains admin capabilities. Both often stem from the same root cause: trusting client-supplied identifiers without server-side verification.
Consider this vulnerable Express.js endpoint:
// VULNERABLE: No authorization check
app.get('/api/users/:userId/profile', async (req, res) => {
const { userId } = req.params;
// Attacker changes userId in URL to access any user's data
const profile = await db.getUserProfile(userId);
res.json(profile);
});
An attacker simply changes the userId parameter to enumerate through user profiles. The fix requires verifying the authenticated user has permission to access the requested resource:
// SECURE: Authorization check before data access
app.get('/api/users/:userId/profile', authenticate, async (req, res) => {
const { userId } = req.params;
const authenticatedUserId = req.user.id;
// Check ownership or admin role
if (userId !== authenticatedUserId && !req.user.roles.includes('admin')) {
return res.status(403).json({ error: 'Access denied' });
}
const profile = await db.getUserProfile(userId);
res.json(profile);
});
Better yet, avoid exposing sequential IDs entirely. Use UUIDs and always derive the user context from the authenticated session rather than URL parameters when possible.
Injection & Cryptographic Failures (#2-3)
Injection attacks remain devastating despite being well-understood. SQL injection, NoSQL injection, LDAP injection, and OS command injection all follow the same pattern: untrusted data gets interpreted as code or commands.
Here’s a classic SQL injection vulnerability:
# VULNERABLE: String concatenation with user input
def get_user(username):
query = f"SELECT * FROM users WHERE username = '{username}'"
return db.execute(query)
# Attacker input: ' OR '1'='1' --
# Resulting query: SELECT * FROM users WHERE username = '' OR '1'='1' --'
The fix uses parameterized queries, which separate data from code:
# SECURE: Parameterized query
def get_user(username):
query = "SELECT * FROM users WHERE username = %s"
return db.execute(query, (username,))
Cryptographic failures expose sensitive data through weak algorithms, poor key management, or missing encryption. The most common mistake? Storing passwords with fast hashing algorithms.
import hashlib
import bcrypt
# VULNERABLE: MD5 is fast and rainbow-table friendly
def hash_password_bad(password):
return hashlib.md5(password.encode()).hexdigest()
# SECURE: bcrypt is slow by design and includes salt
def hash_password_good(password):
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode(), salt)
def verify_password(password, hashed):
return bcrypt.checkpw(password.encode(), hashed)
MD5 can be brute-forced at billions of attempts per second. bcrypt’s work factor makes each attempt computationally expensive, rendering brute-force attacks impractical.
Insecure Design & Security Misconfiguration (#4-5)
Insecure Design is new to the 2021 list and represents a fundamental shift in thinking. You can implement a flawed design perfectly and still have a vulnerable application. Security must be considered during architecture, not patched in afterward.
Threat modeling identifies risks before code exists. Ask questions like: What happens if an attacker controls this input? What’s the blast radius if this component is compromised? Can rate limiting prevent abuse?
Security Misconfiguration covers the operational side: default credentials left unchanged, unnecessary services running, overly permissive CORS policies, and verbose error messages leaking implementation details.
# VULNERABLE: Stack traces exposed to users
from flask import Flask
app = Flask(__name__)
app.config['DEBUG'] = True # Never in production!
@app.route('/api/data')
def get_data():
# Exception details visible to attackers
result = risky_operation()
return result
# SECURE: Generic errors with internal logging
import logging
from flask import Flask, jsonify
app = Flask(__name__)
app.config['DEBUG'] = False
logger = logging.getLogger(__name__)
@app.errorhandler(Exception)
def handle_exception(e):
# Log full details internally
logger.exception("Unhandled exception occurred")
# Return generic message to client
return jsonify({
'error': 'An unexpected error occurred',
'reference': generate_error_id()
}), 500
Stack traces reveal framework versions, file paths, database schemas, and third-party libraries—all valuable reconnaissance for attackers.
Vulnerable Components & Authentication Failures (#6-7)
Vulnerable and Outdated Components represent supply chain risk. Your application inherits every vulnerability in its dependency tree. The 2021 Log4Shell vulnerability demonstrated how a single library flaw can compromise thousands of organizations overnight.
Integrate dependency scanning into your CI/CD pipeline:
# GitHub Actions workflow for dependency scanning
name: Security Scan
on: [push, pull_request]
jobs:
dependency-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run npm audit
run: npm audit --audit-level=high
- name: Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
- name: OWASP Dependency Check
uses: dependency-check/Dependency-Check_Action@main
with:
path: '.'
format: 'HTML'
args: --failOnCVSS 7
Authentication Failures include weak password policies, credential stuffing vulnerabilities, session fixation, and missing multi-factor authentication. Implement defense in depth:
# Secure session configuration
from flask import Flask
from flask_session import Session
app = Flask(__name__)
app.config.update(
SESSION_COOKIE_SECURE=True, # HTTPS only
SESSION_COOKIE_HTTPONLY=True, # No JavaScript access
SESSION_COOKIE_SAMESITE='Lax', # CSRF protection
PERMANENT_SESSION_LIFETIME=1800, # 30-minute timeout
)
# Regenerate session ID after authentication
@app.route('/login', methods=['POST'])
def login():
if validate_credentials(request.form):
session.clear() # Prevent session fixation
session.regenerate()
session['user_id'] = user.id
session['authenticated_at'] = datetime.utcnow()
return redirect('/dashboard')
Software Integrity, Logging & SSRF (#8-10)
Software and Data Integrity Failures target your build pipeline. Attackers compromise CI/CD systems to inject malicious code into legitimate releases. The SolarWinds attack exemplified this threat—18,000 organizations installed backdoored updates from a trusted vendor.
Verify dependencies using lockfiles and integrity checksums. Sign your releases. Implement least-privilege access for build systems.
Insufficient Logging and Monitoring means attacks go undetected. Log authentication events, access control failures, input validation failures, and application errors. But logging alone isn’t enough—you need alerting and incident response procedures.
Server-Side Request Forgery (SSRF) tricks your server into making requests to unintended destinations. This vulnerability gained its own Top 10 category after high-profile attacks against cloud metadata services.
# VULNERABLE: User-controlled URL fetching
import requests
from flask import Flask, request
@app.route('/fetch')
def fetch_url():
url = request.args.get('url')
# Attacker uses: ?url=http://169.254.169.254/latest/meta-data/
# Fetches AWS credentials from metadata service
response = requests.get(url)
return response.text
# SECURE: URL allowlist and validation
from urllib.parse import urlparse
ALLOWED_DOMAINS = {'api.trusted-service.com', 'cdn.example.com'}
def is_url_allowed(url):
try:
parsed = urlparse(url)
# Reject internal IPs and non-HTTPS
if parsed.scheme != 'https':
return False
if parsed.hostname not in ALLOWED_DOMAINS:
return False
# Additional checks for IP addresses
if is_internal_ip(parsed.hostname):
return False
return True
except Exception:
return False
@app.route('/fetch')
def fetch_url():
url = request.args.get('url')
if not is_url_allowed(url):
return jsonify({'error': 'URL not permitted'}), 403
response = requests.get(url, timeout=5)
return response.text
Building a Security-First Development Culture
Technical controls matter, but culture determines whether they’re consistently applied. Here’s how to embed security into your development process:
Integrate security testing into CI/CD. Every pull request should trigger static analysis (SAST), dependency scanning, and secret detection. Block merges when critical issues are found.
# Pre-commit hooks for local security checks
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
- repo: https://github.com/PyCQA/bandit
rev: 1.7.5
hooks:
- id: bandit
args: ['-ll', '-ii']
Create security code review checklists. Reviewers should verify authorization checks, input validation, output encoding, and proper error handling. Make security a required approval for sensitive changes.
Invest in developer training. Abstract knowledge doesn’t stick—use hands-on exercises. Platforms like OWASP WebGoat, HackTheBox, and PortSwigger Web Security Academy let developers exploit vulnerabilities themselves, building intuition for what to avoid.
Conduct threat modeling for new features. Before writing code, diagram data flows and identify trust boundaries. Ask what could go wrong at each step. Document assumptions and mitigations.
Run regular penetration tests. Automated tools catch common issues, but skilled testers find business logic flaws and chained vulnerabilities that scanners miss.
The OWASP Top 10 isn’t a checklist to complete once—it’s a framework for ongoing vigilance. Attacks evolve, new vulnerability classes emerge, and yesterday’s secure code becomes tomorrow’s breach. Build systems and habits that catch problems early, and treat every security finding as a learning opportunity for the entire team.