StackPractices
intermediate By Mathias Paulenko

Web Application Security (OWASP Top 10)

A developer-focused walkthrough of the OWASP Top 10 — injection, XSS, broken access control, SSRF and the rest — with prevention code and a threat-modeling workflow.

Introduction

The OWASP Top 10 is the standard awareness document for web application security risks — a consensus list of what actually gets applications breached. The 2021 edition reordered the list around three big movers: broken access control jumped to #1, software and data integrity failures debuted, and XSS was folded into the broader Injection category.

This guide walks each risk with developer-focused prevention: what the failure looks like, the code pattern that fixes it, and a checklist you can run against your own service. It pairs well with the API Security Checklist for endpoint-by-endpoint review, and with the companion files in the security companion repo if you want ready-to-use configs.

How the Risks Stack Up

Not all ten risks deserve the same effort. The pragmatic ordering for most teams:

flowchart diagram: Threat surface

Sections 1–10 below follow the official ordering, so the numbers match the OWASP document you’re likely quoting.

1. Broken Access Control

Risk: Users access resources or perform actions outside their permissions. Access control failures are the most common OWASP finding — IDOR (insecure direct object reference), function-level privilege escalation, forced browsing to admin paths, and CORS policies that trust *.

Prevention

# Bad: trusting a client-provided user ID
@app.route("/api/orders/<order_id>")
def get_order(order_id):
    order = db.query(f"SELECT * FROM orders WHERE id = {order_id}")
    return jsonify(order)  # any user can see any order!

# Good: verify the authenticated user owns the resource
@app.route("/api/orders/<order_id>")
@login_required
def get_order(order_id):
    order = Order.query.filter_by(
        id=order_id,
        user_id=current_user.id  # enforce ownership
    ).first_or_404()
    return jsonify(order.to_dict())

The two tricks worth remembering: check authorization after authentication on every request (not just on login), and scope every query to the caller — WHERE id = ? AND user_id = ?, never just WHERE id = ?.

Watch function-level access too: a route like /admin/users protected only by a hidden frontend link isn’t protected at all — the path is guessable, and “security through obscurity” fails the moment someone fuzzes for /admin, /internal, /api/v1/users. Enforce the role check inside the handler, not in the UI.

Checklist

  • Deny by default — return 403 unless explicitly allowed
  • Validate resource ownership on every request, including PUT/PATCH/DELETE
  • Disable directory listing and server-side path traversal
  • Rate limit API access to slow down automated enumeration
  • Scope CORS to the origins that actually need it — no Access-Control-Allow-Origin: * on authenticated endpoints

2. Cryptographic Failures

Risk: Sensitive data is exposed through weak, missing, or homegrown encryption. This category replaced “Sensitive Data Exposure” in 2021 — the emphasis moved from the leak to the crypto decisions that caused it.

Prevention

# Bad: plaintext storage
user = {"ssn": "123-45-6789", "password": "abc123"}

# Good: hash passwords, encrypt PII at rest
from bcrypt import hashpw, gensalt
from cryptography.fernet import Fernet

hashed_password = hashpw("user_password".encode(), gensalt())

cipher = Fernet(ENCRYPTION_KEY)  # key from a secrets manager, not the repo
encrypted_ssn = cipher.encrypt("123-45-6789".encode())

Two rules that cover most of this category: never roll your own cipher, and hash passwords — don’t encrypt them. Hashing is one-way by design; encryption is reversible, which is exactly what you don’t want for passwords.

Checklist

  • Hash passwords with bcrypt, Argon2id, or PBKDF2 (never MD5/SHA-1)
  • Encrypt sensitive data at rest (PII, health data, financial data) with managed keys — KMS, Vault, or HSM
  • Use TLS 1.2+ for all data in transit; redirect HTTP to HTTPS
  • Don’t cache sensitive data in client storage (localStorage for tokens)
  • Classify your data first — you can’t protect what you haven’t labeled

3. Injection

Risk: Untrusted data is sent to an interpreter as part of a command or query. SQLi is the famous one, but this bucket also holds command injection, LDAP/XML injection — and, since 2021, XSS.

SQL Injection Prevention

# Bad: string interpolation
query = f"SELECT * FROM users WHERE name = '{user_input}'"

# Good: parameterized queries — the value never becomes SQL
cursor.execute("SELECT * FROM users WHERE name = %s", (user_input,))

Cross-Site Scripting (XSS) Prevention

XSS is injection where the interpreter is the victim’s browser. Three variants worth knowing: reflected (payload in the URL, echoed back), stored (payload persisted in the DB, served to other users), and DOM-based (payload manipulated client-side only).

// Bad: raw user input straight into the DOM
el.innerHTML = comment;              // <script> executes

// Good: text content, never HTML
el.textContent = comment;
# Server side: always use the templating engine's auto-escaping.
# Jinja2 escapes by default — this is what gets people hurt:
{{ user_comment | safe }}     # <-- 'safe' disables escaping, don't do this
{{ user_comment }}            # escaped, correct

A strict Content Security Policy is the backstop: default-src 'self' with no unsafe-inline kills most inline-script payloads even when output encoding is missed.

Command Injection Prevention

# Bad
os.system(f"convert {user_filename} output.png")

# Good: validate and whitelist
import subprocess
allowed_extensions = {".png", ".jpg", ".jpeg"}
if not any(user_filename.endswith(ext) for ext in allowed_extensions):
    raise ValueError("Invalid file type")
subprocess.run(["convert", user_filename, "output.png"], check=True)

Checklist

  • Use parameterized queries for all database access
  • Escape/encode output for its context — HTML, attribute, JS, URL contexts need different encoders
  • Validate and whitelist user input before using it in commands
  • Deploy a strict CSP; avoid innerHTML and eval in client code
  • Escape special characters in LDAP, XML, and OS commands

4. Insecure Design

Risk: Missing or ineffective security controls in the application’s architecture — the flaws you can’t patch because the design itself is wrong. OWASP calls this out separately because “perfectly implemented” insecure designs are still insecure.

Prevention

  • Run threat modeling on sensitive flows before building them — the STRIDE-lite question “what can go wrong here?” is enough for most teams
  • Validate business logic, not just syntax (e.g., “a user can refund once” is a rule code can enforce; “orders arrive in the right order” isn’t)
  • Add anti-automation to flows worth attacking: signup, password reset, coupons, search scrapers
  • Keep a secure development lifecycle: design review for risky changes, security cases in code review
# Anti-automation on password reset
def request_password_reset(email):
    if rate_limiter.is_limited(f"reset:{email}"):
        raise TooManyRequests("Try again later")
    send_reset_email(email)
    rate_limiter.increment(f"reset:{email}")

A Minimal Threat-Modeling Session

You don’t need a formal framework to start. For each sensitive flow:

  1. Draw the data flow — where does input enter, what trusts it, where does it go?
  2. Ask for each trust boundary: what happens if a malicious party controls the input?
  3. Rank by blast radius and probability — an unauthenticated path to user data is top priority
  4. Write the controls into tickets. If the list is empty after 30 minutes, you haven’t looked hard enough.

5. Security Misconfiguration

Risk: Incomplete or ad-hoc configurations, default accounts, unnecessary capabilities. This is the “unforced error” category — the app is secure but the stack is soft.

Prevention Checklist

  • Remove default accounts, credentials, and sample applications
  • Disable unnecessary capabilities, ports, and HTTP methods (OPTIONS, TRACE off unless needed)
  • Send security headers — see security headers recipe for the full set: Strict-Transport-Security, Content-Security-Policy, X-Frame-Options/frame-ancestors, X-Content-Type-Options, Referrer-Policy, Permissions-Policy
  • Keep all frameworks, libraries, and OS patches current
  • Run in minimal privilege mode (not root)
  • Turn off verbose error handling in production — stack traces leak internals
  • Separate production and staging configs; secrets live in a secrets manager, not env files in the repo

6. Vulnerable and Outdated Components

Risk: Running components with known vulnerabilities. Every dependency you ship is code you didn’t review — and attackers diff CVEs against your package.json faster than you patch.

Prevention

# Scan dependencies for known CVEs
npm audit
pip-audit
snyk test

The workflow matters more than the tool: scan on every CI run, pin versions (lockfiles over ranges), and treat “updates available” as a security signal, not routine maintenance. If a dependency is abandoned, migrate off it — an unpatched transitive dependency is how Log4Shell got half the industry.

Checklist

  • Maintain a software bill of materials (SBOM) — you can’t patch what you don’t know you ship
  • Subscribe to security advisories for critical dependencies
  • Remove unused dependencies — less surface, less noise in scans
  • Test updates in staging before production
  • Don’t run latest or loose semver in production artifacts

7. Identification and Authentication Failures

Risk: Authentication weaknesses allow credential stuffing, brute force, or session hijacking. Rate limits, MFA, and sane session handling kill most of this category.

Prevention

# Login flow: MFA + generic errors + short-lived sessions
@app.route("/login", methods=["POST"])
def login():
    user = authenticate(request.json)
    if not user:
        # Generic error prevents user enumeration
        raise Unauthorized("Invalid credentials")

    if user.mfa_enabled:
        session["pending_user_id"] = user.id
        return {"mfa_required": True}

    create_session(user)
    return {"token": generate_jwt(user)}

Small decisions with real payoff: the “invalid credentials” message that doesn’t reveal whether the email exists; a pending_user_id session flag instead of “logged in” before MFA completes; sessions invalidated on password change.

Checklist

  • Implement multi-factor authentication (MFA) — even TOTP moves the needle massively
  • Enforce strong password policies (length > 12 beats complexity rules)
  • Rate limit login attempts — exponential backoff or lockout
  • Use secure session tokens (random, long, HttpOnly + Secure + SameSite cookies)
  • Invalidate sessions on password change and logout
  • Log authentication failures for pattern detection (see §9)

8. Software and Data Integrity Failures

Risk: Insecure deserialization, untrusted CI/CD pipelines, and auto-updates without verification. This one entered the list in 2021 for a reason — supply chain attacks (SolarWinds, the xz backdoor attempt) made it real.

Prevention

# Bad: deserializing untrusted data with pickle
import pickle
data = pickle.loads(user_input)  # arbitrary code execution!

# Good: use JSON with schema validation
import json
from marshmallow import Schema, fields

data = json.loads(user_input)
schema = UserSchema()
result = schema.load(data)  # validates structure

Pipeline integrity is the same problem upstream: unsigned artifacts, mutable tags, build steps that download and run arbitrary scripts. Pin actions and images by digest, verify signatures where they exist, and never trust a build step you can’t audit.

Checklist

  • Sign and verify serialized data integrity (HMAC or asymmetric signatures)
  • Verify CI/CD pipeline integrity — signed commits, immutable tags, pinned action digests
  • Use Subresource Integrity (integrity= hashes) for CDN scripts in HTML
  • Don’t auto-update without cryptographic verification

9. Security Logging and Monitoring Failures

Risk: Insufficient logging lets attackers stay invisible — the median breach goes undetected for weeks, and “we had no logs” is the postmortem nobody wants to write.

Prevention

import structlog

logger = structlog.get_logger()

# Log security events with context — never the password, never the token
logger.info(
    "user_login",
    user_id=user.id,
    ip_address=request.remote_addr,
    user_agent=request.headers.get("User-Agent"),
    success=True
)

What to log: authentication success/failure, access-control denials, input validation errors, and anything that looks like probing (404s on admin paths, malformed payloads). What not to log: passwords, full tokens, session IDs, or raw PII — logs become an attack surface themselves.

Checklist

  • Log all authentication events (success and failure)
  • Log access control failures and validation errors
  • Send alerts on suspicious patterns — repeated failures from one IP, impossible travel, mass enumeration
  • Keep logs tamper-resistant: append-only, centralized, with retention defined
  • Alert on the patterns, not just the volume — one credential-stuffing burst looks like a blip on a flat chart

10. Server-Side Request Forgery (SSRF)

Risk: The server makes requests to unintended destinations based on user input. The classic victim is a “fetch this URL” feature — webhooks, import-by-URL, PDF generators, URL previews — that can be aimed at internal services, cloud metadata endpoints, or anything on the network.

Prevention

# Bad: user controls the URL
url = request.json.get("webhook_url")
requests.post(url, data=sensitive_data)

# Good: scheme + host allowlist, and block internal IP ranges
from urllib.parse import urlparse
import ipaddress
import socket

ALLOWED_HOSTS = {"api.example.com", "hooks.slack.com"}
ALLOWED_SCHEMES = {"https"}

def safe_webhook_call(user_url, data):
    parsed = urlparse(user_url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        raise ValueError("Only https URLs allowed")
    if parsed.hostname not in ALLOWED_HOSTS:
        raise ValueError("URL not in allowlist")

    # Resolve and reject internal IPs (metadata, loopback, RFC 1918)
    try:
        ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
        if not ip.is_global:
            raise ValueError("Internal IP not allowed")
    except socket.gaierror:
        raise ValueError("Cannot resolve host")

    return requests.post(user_url, json=data, timeout=5)

Two tricks the basic example misses: validating only the hostname is bypassable (evil.com resolving to 169.254.169.254 — the AWS metadata IP — or DNS rebinding), and file:///gopher:///ftp:// schemes read local files or hit internal protocols. If the endpoint is public-facing, an egress firewall on the service’s outbound traffic is the better second layer.

Checklist

  • Allowlist scheme, host, and resolved IP for outgoing requests
  • Block internal ranges explicitly: 169.254.169.254 (cloud metadata), loopback, RFC 1918, link-local
  • Disable URL schemes you don’t need (file://, ftp://, gopher://)
  • Put an egress firewall or proxy between the app and the internet for URL-fetching endpoints
  • Don’t return raw responses — forward only what’s needed, so the endpoint can’t be used as a blind SSRF read primitive

Verify What You Deployed

Checklists without verification are wishes. Three cheap checks that catch real gaps:

# 1. Are your security headers actually served? (A05)
curl -sI https://your-app.example/ | grep -iE "strict-transport|content-security|x-frame|x-content|referrer-policy"

# 2. Does your "protected" route answer to an unauthenticated request? (A01/A07)
curl -s -o /dev/null -w "%{http_code}" https://your-app.example/api/admin/users
# anything that isn't 401/403 is a finding
# 3. Baseline DAST scan against staging — OWASP ZAP, Docker, ~2 minutes
docker run -t zaproxy/zap-stable zap-baseline.py -t https://staging.your-app.example

The ZAP baseline is passive-by-default — it crawls and flags the injection/config-class issues without active exploitation, so it’s safe for a smoke check. Save the active-scan mode for a dedicated window on a disposable environment.

A Baseline for a New Project

If you’re starting a service today and only do five things, do these — they cover the top half of the list:

  1. Parameterized everything — ORM or bound parameters, zero string-built queries.
  2. Ownership checks in every handler — WHERE user_id = ? as a reflex, plus a role check on admin paths.
  3. bcrypt + HTTPS + security headers — passwords hashed, TLS terminated correctly, the standard header set deployed.
  4. MFA on the login — even TOTP moves the needle massively.
  5. A log line per security event — auth attempts, denials, validation failures, and an alert on bursts.

Everything else on this page layers on top of that foundation.

Common Mistakes

  • “We’re done after one audit”: security posture decays; run the checklists per release, not once
  • Trusting user input anywhere a URL or file path is built: the request object is the attacker
  • Storing secrets in source code or logs: rotate them first, move them to a secrets manager second
  • “They’re just headers”: CSP alone prevents most XSS payloads even when encoding is missed
  • Logging everything except the security events: brute force looks like background noise until you chart failures per IP

See Also

Frequently Asked Questions

Should I fix all OWASP Top 10 items before shipping?

Address the exploitable-by-default items first: broken access control, injection (including XSS), cryptographic failures, and SSRF if you fetch user-supplied URLs. The rest phase in based on your threat model — a blog doesn't need the same posture as a payment API.

How often should I review the OWASP Top 10?

The list updates every 3–4 years, but the threats move faster. Review your posture quarterly and treat each dependency update as a mini-review — the vulnerable-components risk changes daily.

Is the OWASP Top 10 enough for compliance?

It's a starting point, not a program. For actual compliance work you need the OWASP ASVS level appropriate to your risk, threat modeling on new designs, and periodic penetration tests.

Which items are most exploitable in practice?

Broken access control and injection — they're the top of the list for a reason. IDORs are trivially enumerable, and injection needs no specialized tooling. SSRF is the sleeper: any feature that fetches user-supplied URLs deserves a second look.

What tools automate parts of this?

DAST scanners like OWASP ZAP probe running apps for the injection/config issues; SCA tools (npm audit, pip-audit, Snyk, Dependabot) handle §6; semgrep catches insecure patterns in code review. None of them replace the design review in §4.