OWASP Top 10 (2021)

For learning and securing your own systems. Never test against systems you don't own. The "vulnerable" snippets in this guide are deliberately broken so we can study them. Do not deploy them, and do not use them to probe applications you don't have written permission to test.

The OWASP Top 10 is the de-facto reference for the most common, most damaging classes of web application security risk. The 2021 list reorganized things — some categories were merged, new ones (like Insecure Design and SSRF) were added — and it remains the version many compliance frameworks point at. We'll walk each category with a clearly-labelled vulnerable snippet, a fixed version, and a short checklist of practices that prevent the whole class.

A01:2021 — Broken Access Control

Authorization decisions are wrong. The user is authenticated, but the server lets them perform actions or read data they shouldn't be able to — accessing another user's invoice by changing the ID in the URL, calling an admin endpoint without being an admin, escalating role flags, and so on. This is the #1 issue in the 2021 list.

Vulnerable (Node.js / Express)

// ⚠️ Vulnerable: no ownership check, any authenticated user can read any invoice
app.get('/api/invoices/:id', requireLogin, async (req, res) => {
    const invoice = await db.invoice.findById(req.params.id);
    res.json(invoice);
});

Fixed

// ✅ Fixed: server-side ownership check
app.get('/api/invoices/:id', requireLogin, async (req, res) => {
    const invoice = await db.invoice.findById(req.params.id);
    if (!invoice || invoice.ownerId !== req.user.id) {
        return res.sendStatus(404); // 404, not 403 — don't leak existence
    }
    res.json(invoice);
});

Defensive checklist

  • Deny by default; every endpoint requires an explicit allow rule.
  • Authorization checks live on the server. Never trust client-side gating.
  • Use object-level checks ("does this user own this resource?") for every record fetch.
  • Return 404 instead of 403 for objects the user can't access, to avoid leaking existence.
  • Log authorization failures and alert on bursts (probable enumeration).

A02:2021 — Cryptographic Failures

Sensitive data is exposed because of weak or absent cryptography. The 2021 rename emphasizes the root cause: bad crypto or none at all, not just the resulting leak. Storing passwords as plain MD5, transmitting credentials over plain HTTP, hardcoding secrets, using deprecated algorithms (DES, RC4, SHA-1) all live here.

Vulnerable (Python)

# ⚠️ Vulnerable: passwords stored as unsalted MD5
import hashlib
def store_password(user_id, password):
    db.execute(
        "UPDATE users SET pw_hash = %s WHERE id = %s",
        (hashlib.md5(password.encode()).hexdigest(), user_id),
    )

Fixed

# ✅ Fixed: bcrypt/argon2 with per-user salt, cost factor tuned for hardware
import bcrypt
def store_password(user_id, password):
    hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
    db.execute(
        "UPDATE users SET pw_hash = %s WHERE id = %s",
        (hashed.decode(), user_id),
    )

def verify_password(user_id, password):
    row = db.fetchone("SELECT pw_hash FROM users WHERE id = %s", (user_id,))
    return row and bcrypt.checkpw(password.encode(), row["pw_hash"].encode())

Defensive checklist

  • Hash passwords with argon2id, bcrypt, or scrypt. Never plain SHA-* or MD5.
  • Use TLS 1.2+ (prefer 1.3) for every channel. See our TLS deep dive.
  • For symmetric encryption, use an authenticated mode (AES-GCM, ChaCha20-Poly1305). Never ECB.
  • Generate random material with secrets/crypto.randomBytes//dev/urandom, not random.random().
  • Store secrets in a vault (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault), not in source.

A03:2021 — Injection

Untrusted input crosses an interpreter boundary unsanitized: SQL, OS commands, LDAP, XPath, NoSQL, template engines. The fix is the same in every flavour: don't concatenate; parameterize.

Vulnerable (PHP, SQL injection)

<?php
// ⚠️ Vulnerable: classic SQL injection via string concatenation
$user = $_GET['user'];
$result = mysqli_query($conn, "SELECT id, email FROM users WHERE name = '$user'");
?>

Fixed

<?php
// ✅ Fixed: prepared statement with bound parameter
$stmt = $conn->prepare("SELECT id, email FROM users WHERE name = ?");
$stmt->bind_param("s", $_GET['user']);
$stmt->execute();
$result = $stmt->get_result();
?>

Defensive checklist

  • Parameterized queries / prepared statements for every database call.
  • For ORMs, use the query builder; do not stitch user input into raw SQL fragments.
  • For shell commands, use execve-style APIs that take an argv array, not a shell string.
  • Validate input against an allow-list where possible (especially for column names and ORDER BY targets that cannot be bound).
  • Run with a least-privilege database user — the web app shouldn't be using a DBA account.

A04:2021 — Insecure Design

Insecure Design is the 2021 list's nod to architecture-level issues — features that are working as designed but the design itself is the problem. Examples: a password-reset flow with no rate limit, a "remember me" cookie that never expires, a checkout flow that trusts a hidden price field from the browser.

Vulnerable (Python / Flask)

# ⚠️ Vulnerable: server trusts a "price" field submitted by the client
@app.route('/checkout', methods=['POST'])
def checkout():
    item_id = request.form['item_id']
    price   = float(request.form['price'])    # ← client-supplied price!
    charge_card(current_user.card, amount=price)
    create_order(current_user.id, item_id, price)
    return "OK"

Fixed

# ✅ Fixed: derive price server-side from the trusted product catalog
@app.route('/checkout', methods=['POST'])
def checkout():
    item_id = request.form['item_id']
    item    = Item.query.get_or_404(item_id)
    price   = item.price                       # ← authoritative source

    if not user_can_purchase(current_user, item):
        abort(403)

    charge_card(current_user.card, amount=price)
    create_order(current_user.id, item.id, price)
    return "OK"

Defensive checklist

  • Do threat modeling early (STRIDE, attack trees). Write down what each feature is and is not supposed to allow.
  • Never trust client-controlled fields for security or pricing decisions. Re-derive on the server.
  • Apply rate limits to anything that can be brute forced or enumerated (login, password reset, signup).
  • Define abuse cases alongside use cases. "What happens if 1000 of these run in parallel?"

A05:2021 — Security Misconfiguration

Defaults left on, debug pages exposed, verbose stack traces shown to end users, unnecessary services running, S3 buckets world-readable, CORS set to * with credentials.

Vulnerable (Python / Flask)

# ⚠️ Vulnerable: debug mode in production, stack traces leak source & secrets
from flask import Flask
app = Flask(__name__)

if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True)   # ← debug=True in prod

Fixed

# ✅ Fixed: debug only when explicitly opted-in; serve behind a real WSGI/ASGI server
import os
from flask import Flask
app = Flask(__name__)
app.config['DEBUG'] = os.environ.get('FLASK_DEBUG') == '1'

# In production, run with gunicorn / uvicorn / waitress, e.g.:
#   gunicorn -w 4 -b 127.0.0.1:8000 myapp:app

Defensive checklist

  • Disable debug modes, directory listings, default sample apps in production.
  • Add hardening headers: Content-Security-Policy, X-Content-Type-Options: nosniff, Strict-Transport-Security, Referrer-Policy, Permissions-Policy.
  • Lock down CORS — explicit origin list, not * with credentials.
  • Run automated configuration scans (CIS benchmarks, Lynis, AWS Config rules) on every environment.
  • Keep dev/staging/prod configs separate; secrets out of source control.

A06:2021 — Vulnerable and Outdated Components

Using a library or runtime with a known CVE. This is what struck Equifax in 2017 (Struts), Log4Shell in 2021, and dozens of less famous incidents in between. The fix isn't glamorous: maintain a software bill of materials (SBOM), patch promptly, automate the boring parts.

Vulnerable (Node.js / npm)

// ⚠️ Vulnerable: pinned to an old, known-bad version
{
  "dependencies": {
    "express": "^3.0.0",       // EOL, multiple CVEs
    "lodash": "4.17.10"        // CVE-2019-10744 prototype pollution
  }
}

Fixed

// ✅ Fixed: current major, scanned by CI
{
  "dependencies": {
    "express": "^4.19.2",
    "lodash": "^4.17.21"
  },
  "scripts": {
    "audit": "npm audit --omit=dev --audit-level=high"
  }
}

Defensive checklist

  • Maintain an inventory of every dependency, language runtime, and OS package in production. Generate an SBOM (SPDX or CycloneDX).
  • Subscribe to vulnerability feeds for your stack (GitHub Dependabot, npm audit, pip-audit, OSV, distro security advisories).
  • Block CI on high/critical vulnerabilities; track medium for remediation.
  • Remove unused dependencies — every one is an attack-surface line item.
  • Avoid pinning to a single old version "because it works." Pin a range and update on a schedule.

A07:2021 — Identification and Authentication Failures

Weak password handling, predictable session tokens, no rate limiting on login, missing MFA, credential stuffing protection absent.

Vulnerable (Python / Flask)

# ⚠️ Vulnerable: no rate limit; password comparison leaks via timing
@app.route('/login', methods=['POST'])
def login():
    user = User.query.filter_by(email=request.form['email']).first()
    if user and user.password == request.form['password']:   # ← plaintext + ==
        session['user_id'] = user.id
        return redirect('/')
    return "bad login", 401

Fixed

# ✅ Fixed: hashed password, constant-time check, rate-limited, generic error
from werkzeug.security import check_password_hash
from flask_limiter import Limiter
limiter = Limiter(get_remote_address, app=app)

@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute; 20 per hour")
def login():
    email    = request.form['email']
    password = request.form['password']

    user = User.query.filter_by(email=email).first()
    # check_password_hash uses bcrypt/argon2 and a constant-time compare
    if user and check_password_hash(user.pw_hash, password):
        session.regenerate()                      # avoid fixation
        session['user_id'] = user.id
        return redirect('/')

    return "Invalid email or password", 401       # generic, no enumeration

Defensive checklist

  • Modern password hashing (see A02). No plaintext, no MD5/SHA-1.
  • Constant-time comparisons (hmac.compare_digest, crypto.timingSafeEqual).
  • Rate-limit authentication endpoints and password resets.
  • Offer (and ideally enforce) MFA — TOTP, WebAuthn, or push-based.
  • Rotate the session ID on login and privilege change to defeat fixation.
  • Generic error messages on login; don't reveal whether the email exists.
  • Check passwords against the Have I Been Pwned breach list on registration / change.

A08:2021 — Software and Data Integrity Failures

Unverified updates, unsigned plugins, deserialization of untrusted data, pulling third-party scripts from CDNs without subresource integrity. The 2021 category absorbed the old "insecure deserialization."

Vulnerable (HTML)

<!-- ⚠️ Vulnerable: third-party script with no integrity check -->
<script src="https://cdn.example.org/somelib.js"></script>

Fixed

<!-- ✅ Fixed: Subresource Integrity (SRI) pin + crossorigin -->
<script
  src="https://cdn.example.org/somelib.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"></script>

Vulnerable (Python, untrusted deserialization)

# ⚠️ Vulnerable: pickle.loads on untrusted input executes arbitrary code
import pickle, base64
data = base64.b64decode(request.cookies['state'])
state = pickle.loads(data)   # ← attacker-controlled bytes → RCE

Fixed

# ✅ Fixed: JSON-only, schema-validated, with signed/encrypted state if needed
import json
from itsdangerous import URLSafeTimedSerializer

s = URLSafeTimedSerializer(app.config['SECRET_KEY'])
state = s.loads(request.cookies['state'], max_age=3600)
# state is now a verified, JSON-safe dict — never executable code

Defensive checklist

  • Sign and verify software updates and plugin packages.
  • Use SRI for every external script you don't control.
  • Never deserialize untrusted binary formats (pickle, Java serialization, PHP unserialize). Use JSON with a schema.
  • Lock CI/CD: signed commits (commit signing or sigstore), branch protection, separate prod release credentials.
  • Audit dependency-confusion risk: private package names should be reserved on public registries.

A09:2021 — Security Logging and Monitoring Failures

The bug isn't in the application — the bug is that nobody noticed when it was attacked. Average dwell time between breach and detection is still measured in months.

Vulnerable (Node.js)

// ⚠️ Vulnerable: failed logins are silently dropped
app.post('/login', async (req, res) => {
    const user = await db.user.findOne({ email: req.body.email });
    if (!user || !verify(req.body.password, user.pwHash)) {
        return res.sendStatus(401);  // ← no log, no metric
    }
    req.session.userId = user.id;
    res.sendStatus(200);
});

Fixed

// ✅ Fixed: structured log, counter increment, alertable
app.post('/login', async (req, res) => {
    const user = await db.user.findOne({ email: req.body.email });
    if (!user || !verify(req.body.password, user.pwHash)) {
        log.warn({
            event: 'auth.login.failed',
            email: req.body.email,
            ip: req.ip,
            userAgent: req.headers['user-agent']
        });
        metrics.increment('auth.login.failed');
        return res.sendStatus(401);
    }

    log.info({ event: 'auth.login.success', userId: user.id, ip: req.ip });
    req.session.userId = user.id;
    res.sendStatus(200);
});

Defensive checklist

  • Log authentication events (success and failure), authorization denials, validation failures, and admin actions.
  • Logs are structured (JSON), centralised (SIEM / log platform), retained per compliance requirements, and tamper-resistant.
  • Never log passwords, full card numbers, or full session tokens — only hashes or last-4.
  • Alert on patterns: brute-force bursts, geo-anomalies, sudden spikes in 403/404.
  • Practice incident response. The first time you read the runbook should not be during an incident.

A10:2021 — Server-Side Request Forgery (SSRF)

The application takes a URL from the user and fetches it server-side. The attacker points it at http://169.254.169.254/ (the cloud metadata service), http://localhost:6379 (an internal Redis), or an internal admin panel that only the application server can reach. SSRF was added as its own category in 2021 because of its real-world impact (Capital One, 2019).

Vulnerable (Python / Flask)

# ⚠️ Vulnerable: user-controlled URL fetched verbatim
import requests
@app.route('/fetch')
def fetch():
    url = request.args['url']
    return requests.get(url).text

Fixed

# ✅ Fixed: allow-list of hosts; resolve once, refuse private IPs; no redirects
import ipaddress, socket
from urllib.parse import urlparse
import requests

ALLOWED_HOSTS = {'images.example.com', 'cdn.example.com'}

def _is_public_ip(host: str) -> bool:
    try:
        ip = ipaddress.ip_address(socket.gethostbyname(host))
    except (ValueError, socket.gaierror):
        return False
    return not (ip.is_private or ip.is_loopback or ip.is_link_local
                or ip.is_reserved or ip.is_multicast)

@app.route('/fetch')
def fetch():
    parsed = urlparse(request.args['url'])
    if parsed.scheme not in ('http', 'https'):
        abort(400)
    if parsed.hostname not in ALLOWED_HOSTS or not _is_public_ip(parsed.hostname):
        abort(400)
    return requests.get(parsed.geturl(), timeout=5, allow_redirects=False).text

Defensive checklist

  • Allow-list the destinations the server is permitted to fetch.
  • Block private, loopback, link-local, reserved, and multicast IP ranges (RFC 1918 plus IPv6 equivalents and cloud metadata 169.254.169.254 / fd00:ec2::254).
  • Disable HTTP redirects, or re-validate the target after each redirect (a redirect can shift to an internal address).
  • Use a separate egress proxy with strict ACLs for outbound HTTP from your app servers.
  • Lock down cloud metadata: on AWS, require IMDSv2 with hop-limit 1.

Summary

The OWASP Top 10 is not the entire universe of web security, but it covers the cases that show up over and over again. Categories like A01 (Broken Access Control) and A03 (Injection) are almost entirely avoidable by following two habits: never trust the client and never concatenate untrusted input into another language. The rest is about hygiene — patching, hashing, logging — and treating security as a first-class design concern rather than something to add at the end.