StackPractices
advanced By Mathias Paulenko

Complete Guide to OWASP Top 10 2025

Mitigate each risk in the OWASP Top 10 2025 list with practical code examples. Covers the new supply chain and exception-handling categories, plus the reordered classics.

The OWASP Top 10 is the standard awareness document for web application security — a consensus ranking of the most critical risks, rebuilt from real-world data every few years. The 2025 edition is the first update since 2021, and it’s more than a reshuffle: two categories are new, one was broadened into something bigger, and SSRF lost its standalone slot entirely.

The list itself is built from two sources: anonymized vulnerability data contributed by testing organizations (hundreds of thousands of real applications analyzed) and a community survey that fills in the risks the raw data underrepresents, like insecure design. So the ranking reflects what actually breaks in production, not what sounds scary on paper.

If you built your security checklist around the 2021 list, several of your priorities need renumbering before anything else.

What changed from 2021 to 2025

2025 rankCategoryWhat happened
A01Broken Access ControlUnchanged at #1 — SSRF was folded in here
A02Security MisconfigurationJumped from #5 to #2, driven by cloud and framework defaults
A03Software Supply Chain FailuresNew — expands the old “Vulnerable Components” into the whole chain: dependencies, build systems, distribution
A04Cryptographic FailuresMoved down from #2
A05InjectionMoved down from #3
A06Insecure DesignMoved down from #4
A07Authentication FailuresSame rank, shorter name (was “Identification and Authentication Failures”)
A08Software or Data Integrity FailuresUnchanged
A09Security Logging & Alerting FailuresRenamed — “Monitoring” became “Alerting”, because logs nobody acts on don’t count
A10Mishandling of Exceptional ConditionsNew — error paths, fail-open defaults, unhandled edge cases

The rest of this guide walks each 2025 category with vulnerable code and working mitigations in Python, JavaScript, and Docker.

One warning before you plan around the ranking: don’t treat A01-to-A10 as a priority order for your own backlog. The rank reflects prevalence across hundreds of thousands of applications. Your exposure depends on your stack and architecture. A team shipping a single-page app behind a WAF cares about injection differently than a team running twenty internal services. Use the list as a checklist for coverage, then order your fixes by what an attacker could actually reach.

A01: Broken Access Control

Broken access control held the #1 spot, and the data behind it’s brutal: the vast majority of applications tested show at least one CWE in this bucket. It covers two failure modes: horizontal escalation, where one user reads or modifies another user’s data, and vertical escalation, where a regular user reaches admin functions.

The vulnerable code

# VULNERABLE: No access control check
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
    user = db.query(User).filter(User.id == user_id).first()
    return user

# VULNERABLE: Trusting client-side role
@app.put("/api/users/{user_id}/role")
def update_role(user_id: int, role: str):
    user = db.query(User).filter(User.id == user_id).first()
    user.role = role  # Anyone can set any role!
    db.commit()

Mitigation

from fastapi import Depends, HTTPException

def get_current_user(token: str = Depends(oauth2_scheme)):
    # Validate token and return user
    user = decode_jwt(token)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid token")
    return user

def require_role(required_role: str):
    def role_checker(current_user: User = Depends(get_current_user)):
        if current_user.role != required_role:
            raise HTTPException(status_code=403, detail="Insufficient permissions")
        return current_user
    return role_checker

@app.get("/api/users/{user_id}")
def get_user(
    user_id: int,
    current_user: User = Depends(get_current_user)
):
    # Horizontal access control: users can only access their own data
    if current_user.id != user_id and current_user.role != "admin":
        raise HTTPException(status_code=403, detail="Access denied")

    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

@app.put("/api/users/{user_id}/role")
def update_role(
    user_id: int,
    role: str,
    current_user: User = Depends(require_role("admin"))
):
    user = db.query(User).filter(User.id == user_id).first()
    user.role = role
    db.commit()
    return {"status": "updated"}

JavaScript mitigation

// Express middleware for access control
function requireOwnership(paramName = 'userId') {
    return (req, res, next) => {
        const resourceId = parseInt(req.params[paramName]);
        if (req.user.id !== resourceId && req.user.role !== 'admin') {
            return res.status(403).json({ error: 'Access denied' });
        }
        next();
    };
}

function requireRole(role) {
    return (req, res, next) => {
        if (req.user.role !== role) {
            return res.status(403).json({ error: 'Insufficient permissions' });
        }
        next();
    };
}

// Apply middleware
app.get('/api/users/:userId', authenticate, requireOwnership(), getUser);
app.put('/api/users/:userId/role', authenticate, requireRole('admin'), updateRole);

Where SSRF lives now

In 2021, Server-Side Request Forgery had its own A10 slot. The 2025 edition folded it into Broken Access Control, which is the right mental model anyway: an SSRF attack is your server accessing a resource it shouldn’t, on the attacker’s behalf. Same root cause (a missing authorization check) on a different actor.

# VULNERABLE: Fetching a user-supplied URL with no validation
import requests

def fetch_url(url: str) -> str:
    response = requests.get(url)
    return response.text
    # url = "http://169.254.169.254/latest/meta-data/" (AWS metadata)
    # url = "http://localhost:8080/admin"
    # url = "http://10.0.0.1/internal-service"
import ipaddress
import socket
import requests
from urllib.parse import urlparse

class SSRFProtector:
    BLOCKED_IP_RANGES = [
        ipaddress.ip_network("10.0.0.0/8"),
        ipaddress.ip_network("172.16.0.0/12"),
        ipaddress.ip_network("192.168.0.0/16"),
        ipaddress.ip_network("169.254.0.0/16"),  # Link-local / cloud metadata
        ipaddress.ip_network("127.0.0.0/8"),     # Loopback
        ipaddress.ip_network("0.0.0.0/8"),
        ipaddress.ip_network("::1/128"),         # IPv6 loopback
        ipaddress.ip_network("fc00::/7"),        # IPv6 ULA
    ]

    ALLOWED_SCHEMES = {"http", "https"}
    ALLOWED_PORTS = {80, 443, 8080, 8443}

    def validate_url(self, url: str) -> bool:
        parsed = urlparse(url)

        if parsed.scheme not in self.ALLOWED_SCHEMES:
            return False

        port = parsed.port or (443 if parsed.scheme == "https" else 80)
        if port not in self.ALLOWED_PORTS:
            return False

        # Resolve the hostname — the DNS answer is what matters, not the string
        try:
            ips = socket.getaddrinfo(parsed.hostname, port)
            for ip_info in ips:
                ip = ipaddress.ip_address(ip_info[4][0])
                for blocked in self.BLOCKED_IP_RANGES:
                    if ip in blocked:
                        return False
        except socket.gaierror:
            return False

        return True

    def safe_fetch(self, url: str, timeout: int = 10) -> str:
        if not self.validate_url(url):
            raise ValueError("URL validation failed")

        # Redirects disabled: each hop would need re-validation anyway
        response = requests.get(url, timeout=timeout, allow_redirects=False)

        if response.status_code in (301, 302, 307, 308):
            location = response.headers.get("Location", "")
            if not self.validate_url(location):
                raise ValueError("Redirect target validation failed")

        return response.text

protector = SSRFProtector()

def fetch_url(url: str) -> str:
    return protector.safe_fetch(url)

The subtle part is socket.getaddrinfo. Validating the hostname string is useless if evil.com resolves to 169.254.169.254. Check the resolved IPs, not the URL text. And revalidate every redirect target, because the first hop can point to a safe URL that 302s straight into your VPC.

A02: Security Misconfiguration

Misconfiguration jumped three places to #2, and the driver is easy to see: more of the attack surface now lives in configuration than in code. Framework defaults, open cloud storage, verbose error pages, default credentials, debug mode left on in production. None of these are bugs you wrote, and that’s exactly why they slip through reviews.

Mitigation

# Secure FastAPI configuration
from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.responses import JSONResponse

app = FastAPI(
    docs_url=None,        # Disable docs in production
    redoc_url=None,
    openapi_url=None
)

# Strict CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://stackpractices.com"],  # Specific origins only
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

# HTTPS redirect
app.add_middleware(HTTPSRedirectMiddleware)

# Security headers middleware
@app.middleware("http")
async def add_security_headers(request, call_next):
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    response.headers["Content-Security-Policy"] = "default-src 'self'"
    response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
    return response

# Generic error handler — no stack traces
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error"}
    )

Docker security configuration

# Secure Dockerfile
FROM python:3.12-slim AS base

# Run as non-root user
RUN useradd -m -u 1001 appuser
USER appuser

WORKDIR /app

# Copy only what's needed
COPY --chown=appuser:appuser requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY --chown=appuser:appuser . .

CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8000/health || exit 1

The pattern to internalize: every default is a decision someone made for you, and attackers know the defaults better than you do. Disable what you don’t use, lock down what you keep, and treat X-XSS-Protection as legacy: modern browsers removed it because it caused more harm than good, so dropping it from your header list is fine.

A03: Software Supply Chain Failures

This is the biggest structural change in the 2025 list. The old “Vulnerable and Outdated Components” asked a narrow question: are your dependencies patched? The new category asks a wider one: can you trust the code you didn’t write, and the pipeline that hands it to you? That covers compromised packages, typosquatting, malicious version bumps, tampered build systems, leaked CI secrets, and unsigned artifacts.

flowchart diagram: Source code

Each arrow is a trust boundary where code you didn’t write becomes code you ship.

Mitigation

# Python: check for known vulnerabilities
pip install pip-audit
pip-audit

# Node.js: check for vulnerabilities
npm audit
npm audit fix

# Java: check dependencies
./gradlew dependencyCheck
# Automated dependency scanning in CI/CD — .github/workflows/security.yml
name: Security Scanning
on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Python dependency check
        run: |
          pip install pip-audit
          pip-audit --strict
      - name: npm audit
        run: |
          npm audit --audit-level=high
      - name: Trivy filesystem scan
        run: |
          trivy fs --severity HIGH,CRITICAL .

Scanning covers the “vulnerable” half. For the “failures” half (an attacker shipping you a malicious version rather than an outdated one), pin dependencies with lockfiles and hashes (pip install --require-hashes, npm ci instead of npm install), generate an SBOM so you can answer “are we affected?” in minutes instead of days, and sign or verify build artifacts before deploying them. Tools like sigstore/cosign make artifact verification practical even for small teams.

A04: Cryptographic Failures

This category moved down two places but the failure modes haven’t changed: weak or broken algorithms, keys hardcoded or committed to the repo, passwords stored in plaintext or hashed with MD5/SHA-1, and sensitive data in transit without TLS.

The vulnerable code

# VULNERABLE: Storing passwords in plaintext
def register_user(username, password):
    user = User(username=username, password=password)  # Plaintext!
    db.add(user)
    db.commit()

# VULNERABLE: Weak hashing
import hashlib
def hash_password(password):
    return hashlib.md5(password.encode()).hexdigest()  # MD5 is broken

# VULNERABLE: Hardcoded encryption key
ENCRYPTION_KEY = "my_secret_key_123"

Mitigation

import bcrypt
import os
import secrets
from cryptography.fernet import Fernet

# Secure password hashing with bcrypt
def hash_password(password: str) -> str:
    salt = bcrypt.gensalt(rounds=12)
    return bcrypt.hashpw(password.encode(), salt).decode()

def verify_password(password: str, hashed: str) -> bool:
    return bcrypt.checkpw(password.encode(), hashed.encode())

def register_user(username: str, password: str):
    hashed = hash_password(password)
    user = User(username=username, password_hash=hashed)
    db.add(user)
    db.commit()

# Encryption with environment-based keys
def get_encryption_key() -> bytes:
    key = os.environ.get("ENCRYPTION_KEY")
    if not key:
        raise RuntimeError("ENCRYPTION_KEY not set")
    return key.encode()

def encrypt_data(plaintext: str) -> str:
    f = Fernet(get_encryption_key())
    return f.encrypt(plaintext.encode()).decode()

def decrypt_data(ciphertext: str) -> str:
    f = Fernet(get_encryption_key())
    return f.decrypt(ciphertext.encode()).decode()

# Secure token generation
def generate_api_token() -> str:
    return secrets.token_urlsafe(32)  # 256-bit token

Two rules cover most of this category: never roll your own crypto, and never store a secret in source control. bcrypt for passwords, Fernet/AES-GCM for data at rest, TLS for data in transit, and a secrets manager or environment variables for keys. For key management itself, see the Complete Guide to Secrets Management.

A05: Injection

Injection fell to #5. Parameterized queries and ORMs pushed classic SQLi down, but it hasn’t gone away. The category covers SQL, NoSQL, command, and LDAP injection, and modern codebases keep finding new interpreters to feed untrusted data into: template engines, shell calls, even LLM prompts.

The vulnerable code

# VULNERABLE: SQL injection
def get_user_by_email(email):
    query = f"SELECT * FROM users WHERE email = '{email}'"
    return db.execute(query)
    # Attack: email = "' OR 1=1 --" returns all users

# VULNERABLE: Command injection
import subprocess
def ping_host(host):
    result = subprocess.run(f"ping -c 1 {host}", shell=True)
    return result.stdout
    # Attack: host = "localhost; rm -rf /"

Mitigation

# SAFE: Parameterized queries
def get_user_by_email(email: str):
    query = "SELECT * FROM users WHERE email = :email"
    return db.execute(query, {"email": email})

# SAFE: ORM with built-in parameterization
def get_user_by_email(email: str):
    return db.query(User).filter(User.email == email).first()

# SAFE: Command execution without shell
import re
import subprocess

def ping_host(host: str):
    if not re.match(r'^[a-zA-Z0-9.\-]+$', host):
        raise ValueError("Invalid hostname")

    result = subprocess.run(
        ["ping", "-c", "1", host],
        capture_output=True,
        text=True,
        shell=False  # Never shell=True with user input
    )
    return result.stdout

# SAFE: NoSQL injection prevention
def find_user(query_dict: dict):
    sanitized = {}
    for key, value in query_dict.items():
        if isinstance(value, dict) and any(k.startswith('$') for k in value):
            raise ValueError("Operators not allowed")
        sanitized[key] = value
    return db.users.find_one(sanitized)

The defense is always the same shape: keep data and code in separate channels. Parameterized queries for SQL, argument arrays instead of shell=True for subprocess, allowlists for anything that can’t be parameterized. The NoSQL example matters more than it looks. MongoDB operators like $where or $gt are injection vectors that classic SQLi filters never touch.

A06: Insecure Design

Insecure design is the only category you can’t patch: it’s about decisions made before code existed. A registration flow with no rate limit, an API that trusts a client-supplied role, password reset without email verification: these work exactly as designed, and that’s the problem.

Mitigation: design patterns that close the gap

# Design pattern: Rate limiting on the registration endpoint
from collections import defaultdict
from datetime import datetime, timedelta

class RegistrationRateLimiter:
    def __init__(self):
        self.attempts: dict[str, list[datetime]] = defaultdict(list)
        self.max_attempts = 3
        self.window_hours = 24

    def can_register(self, ip: str, email: str) -> bool:
        now = datetime.now()
        cutoff = now - timedelta(hours=self.window_hours)

        key = f"{ip}:{email}"
        self.attempts[key] = [t for t in self.attempts[key] if t > cutoff]

        if len(self.attempts[key]) >= self.max_attempts:
            return False

        self.attempts[key].append(now)
        return True

# Design pattern: Secure defaults
class UserAccount:
    def __init__(self, username: str, email: str):
        self.username = username
        self.email = email
        self.is_verified = False  # Default: unverified
        self.is_active = False    # Default: inactive until verified
        self.mfa_enabled = False
        self.failed_login_count = 0
        self.locked_until = None

    def verify_email(self, token: str) -> bool:
        if self._validate_token(token):
            self.is_verified = True
            self.is_active = True
            return True
        return False

# Design pattern: Principle of least privilege
class Permission:
    READ = "read"
    WRITE = "write"
    DELETE = "delete"
    ADMIN = "admin"

class Role:
    VIEWER = {Permission.READ}
    EDITOR = {Permission.READ, Permission.WRITE}
    ADMIN = {Permission.READ, Permission.WRITE, Permission.DELETE, Permission.ADMIN}

def check_permission(user_role: set, required: str) -> bool:
    return required in user_role

The fix for insecure design isn’t a library: it’s threat modeling before you build. Ask what an attacker gains from each endpoint, design abuse cases alongside use cases, and default everything to the most restrictive state (is_active = False until proven otherwise). Retrofitting these decisions after launch costs ten times what designing them costs.

A07: Authentication Failures

Same rank as 2021, shorter name. The failures are familiar: weak passwords allowed, no rate limiting on login, session tokens that never expire, credential stuffing succeeding because there’s no lockout, and password reset flows that leak whether an email exists.

Mitigation

import secrets
from datetime import datetime, timedelta

import bcrypt

class AuthService:
    MAX_LOGIN_ATTEMPTS = 5
    LOCKOUT_DURATION = timedelta(minutes=15)

    def __init__(self, db, email_service):
        self.db = db
        self.email_service = email_service

    def login(self, username: str, password: str) -> dict:
        user = self.db.query(User).filter(User.username == username).first()

        # Always run bcrypt — prevents timing attacks that reveal valid usernames
        if not user:
            bcrypt.checkpw(password.encode(), b"$2b$12$dummyhash")
            raise AuthError("Invalid credentials")

        if user.locked_until and user.locked_until > datetime.now():
            raise AuthError(f"Account locked until {user.locked_until}")

        if not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
            user.failed_login_count += 1
            if user.failed_login_count >= self.MAX_LOGIN_ATTEMPTS:
                user.locked_until = datetime.now() + self.LOCKOUT_DURATION
                user.failed_login_count = 0
            self.db.commit()
            raise AuthError("Invalid credentials")

        # Reset on successful login
        user.failed_login_count = 0
        user.locked_until = None

        token = secrets.token_urlsafe(32)
        user.session_token = token
        user.session_expires = datetime.now() + timedelta(hours=1)
        self.db.commit()

        return {"token": token, "expires_in": 3600}

    def logout(self, token: str):
        user = self.db.query(User).filter(User.session_token == token).first()
        if user:
            user.session_token = None
            user.session_expires = None
            self.db.commit()

    def initiate_password_reset(self, email: str):
        user = self.db.query(User).filter(User.email == email).first()
        if not user:
            # Don't reveal whether the email exists
            return

        reset_token = secrets.token_urlsafe(32)
        user.reset_token = reset_token
        user.reset_expires = datetime.now() + timedelta(hours=1)
        self.db.commit()

        self.email_service.send(user.email, "Password Reset", f"Token: {reset_token}")

The timing-attack line is easy to miss: if the code skips bcrypt.checkpw when the user doesn’t exist, the response time tells an attacker which usernames are valid. Running the hash on a dummy value keeps the timing identical either way. For the broader picture (OAuth, JWT, sessions, MFA), see the Complete Guide to Authentication Patterns.

A08: Software or Data Integrity Failures

Integrity failures sit right next to supply chain failures but answer a different question. A03 asks “can you trust where your code comes from?” A08 asks “can you trust what you’re about to run?” Insecure deserialization, unsigned updates, and trusting webhook payloads or CI artifacts without verification all live here.

Mitigation

import hashlib
import hmac

import jwt

# Verify webhook signatures
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()

    # Constant-time comparison — == leaks timing information
    return hmac.compare_digest(expected, signature)

# Verify downloaded artifacts
def verify_download(content: bytes, expected_sha256: str) -> bool:
    actual = hashlib.sha256(content).hexdigest()
    return hmac.compare_digest(actual, expected_sha256)

# JWT verification with pinned algorithm
def verify_jwt(token: str, public_key: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            public_key,
            algorithms=["RS256"],  # Pin the algorithm — never trust the token's "alg" header
            issuer="https://auth.stackpractices.com",
            audience="stackpractices-api"
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise AuthError("Token expired")
    except jwt.InvalidTokenError:
        raise AuthError("Invalid token")

The classic mistake here is accepting the alg header from the JWT itself. That’s what enabled the algorithm-confusion attacks where an RS256 token gets re-signed as HS256 using the public key as the HMAC secret. Pin the algorithm server-side. Same idea everywhere else: signatures, checksums, and hmac.compare_digest instead of ==, because a plain string comparison leaks byte-by-byte timing.

A09: Security Logging & Alerting Failures

The rename from “Monitoring” to “Alerting” is the whole story. Most breaches sit in logs for months before anyone notices. The 2025 list’s point is that logging without alerting is just expensive storage. You need both the events and a path that wakes someone up when they matter.

Mitigation

import json
import logging
from datetime import datetime

class SecurityLogger:
    def __init__(self):
        self.logger = logging.getLogger("security")
        handler = logging.FileHandler("/var/log/app/security.log")
        handler.setFormatter(logging.Formatter('%(message)s'))
        self.logger.addHandler(handler)
        self.logger.setLevel(logging.INFO)

    def log_auth_event(self, event_type: str, user_id: str, ip: str, success: bool):
        self.logger.info(json.dumps({
            "timestamp": datetime.now().isoformat(),
            "event": event_type,
            "user_id": user_id,
            "ip": ip,
            "success": success,
            "type": "authentication"
        }))

    def log_access_denied(self, user_id: str, resource: str, ip: str):
        self.logger.warning(json.dumps({
            "timestamp": datetime.now().isoformat(),
            "event": "access_denied",
            "user_id": user_id,
            "resource": resource,
            "ip": ip,
            "type": "authorization"
        }))

    def log_security_event(self, event: str, details: dict):
        self.logger.warning(json.dumps({
            "timestamp": datetime.now().isoformat(),
            "event": event,
            "details": details,
            "type": "security"
        }))

# Usage
sec_logger = SecurityLogger()

sec_logger.log_auth_event("login", user.id, request.client.host, True)
sec_logger.log_auth_event("login_failed", username, request.client.host, False)

sec_logger.log_access_denied(user.id, f"/api/users/{target_id}", request.client.host)

sec_logger.log_security_event("sql_injection_attempt", {
    "ip": request.client.host,
    "input": user_input[:200]  # Truncate — don't let attackers fill your disk
})

Structured logs are the easy half. The alerting half is: count login_failed per IP and page someone at a threshold, alert on any access_denied to admin routes, and alarm when error rates spike. An attacker probing your app looks exactly like a sudden burst of 4xx. If you can only do one thing, wire failed-logins-per-IP to an alert; credential stuffing shows up there first.

A10: Mishandling of Exceptional Conditions

The second new category, and the one most teams haven’t heard of yet. It covers what happens when things go wrong: unhandled exceptions, error paths that skip security checks, fail-open defaults, stack traces leaked to users, and resource exhaustion when an attacker triggers errors deliberately. Exceptional conditions are where carefully written code lets its guard down.

The vulnerable code

# VULNERABLE: Fail-open on unexpected errors
def transfer_funds(from_id: int, to_id: int, amount: float):
    try:
        sender = db.query(Account).get(from_id)
        if sender.balance < amount:
            raise InsufficientFunds()
        sender.balance -= amount
        receiver = db.query(Account).get(to_id)
        receiver.balance += amount
        db.commit()
    except Exception as e:
        # Error swallowed — sender already debited, commit half-done?
        logger.error(f"transfer failed: {e}")
        return {"status": "ok"}  # Client thinks it worked!

# VULNERABLE: Stack trace leaked to the client
@app.get("/api/order/{order_id}")
def get_order(order_id: str):
    order = db.execute(f"SELECT * FROM orders WHERE id = '{order_id}'")
    return order  # ValueError/DBError bubbles up as a 500 with internals

Mitigation

class TransferError(Exception):
    pass

def transfer_funds(from_id: int, to_id: int, amount: float) -> dict:
    try:
        with db.transaction():  # Atomic — rolls back on any failure
            sender = db.query(Account).with_for_update().get(from_id)
            if sender is None:
                raise TransferError("sender not found")
            if sender.balance < amount:
                raise TransferError("insufficient funds")
            sender.balance -= amount

            receiver = db.query(Account).with_for_update().get(to_id)
            if receiver is None:
                raise TransferError("receiver not found")
            receiver.balance += amount
    except TransferError as e:
        # Expected failure — safe to surface
        raise HTTPException(status_code=422, detail=str(e))
    # Unexpected exceptions propagate to the global handler → generic 500
    return {"status": "ok"}

@app.get("/api/order/{order_id}")
def get_order(order_id: int):  # int validation fails closed at the router
    order = db.query(Order).get(order_id)
    if not order:
        raise HTTPException(status_code=404, detail="Order not found")
    return order

The principle is fail closed, recover explicitly. A security check that raises should deny by default, not pass by accident, which is why the transfer above uses an atomic transaction instead of hoping the except does the right thing. Expected failures get typed exceptions and proper status codes; unexpected ones fall through to a generic 500 that logs internally and says nothing to the client. The earlier global exception handler in A02 is the safety net that makes this work.

Mistakes that cut across all ten

Some failure patterns show up in every category:

  • Trusting client-side anything. Hidden form fields, roles in JWT claims, “admin” flags in localStorage: if the client sends it, treat it as attacker input.
  • Security added after the fact. Bolting a WAF or an audit pass onto a design with no rate limits or ownership checks fixes the symptoms, not the design.
  • Defaults shipped to production. Debug flags, open CORS, documented endpoints. Whatever the framework gives you by default was chosen for developer convenience, not your threat model.
  • Compliance as a proxy for security. Passing a checklist audit once a year tells you almost nothing about your day-to-day exposure; the categories above are what real attackers actually use.

See Also

Frequently Asked Questions

What actually changed between OWASP Top 10 2021 and 2025?

Two categories are new: Software Supply Chain Failures (A03) and Mishandling of Exceptional Conditions (A10). "Vulnerable and Outdated Components" was absorbed into the broader supply chain category, and SSRF lost its own slot, folding into Broken Access Control. Security Misconfiguration jumped from #5 to #2, and Logging was renamed to Logging & Alerting to stress that monitoring nobody acts on is useless.

Is SSRF still a risk if it's not its own category anymore?

Yes, it moved. It didn't disappear. OWASP folded it into A01 because an SSRF exploit is an access-control failure at heart: your server requests a resource it shouldn't on the attacker's behalf. You still need URL validation, egress controls, and metadata-endpoint blocks. The protection code above still applies, it's just filed under a different letter.

How often should I check my application against the OWASP Top 10?

At least quarterly with automated tools (OWASP ZAP, Burp Suite), plus a manual penetration test annually and a review after major architecture changes. Wire SAST and DAST into CI so every pull request gets checked. The list is an awareness document, but it works as a minimum audit checklist.

What is the difference between SAST and DAST?

SAST analyzes source code without running it and catches patterns like SQL injection sinks and hardcoded secrets. DAST attacks the running application from outside and finds runtime issues like misconfigured headers or auth bypasses. You want both: SAST early in development, DAST against staging.

Should I use a WAF (Web Application Firewall)?

Yes, as a layer. Cloud WAFs (AWS WAF, Cloudflare, Azure Front Door) block commodity attacks like SQLi scanners before they reach your app. But a WAF is defense in depth, not a fix: it buys you time while you patch, it doesn't patch for you.

Which OWASP 2025 category do teams underestimate most?

In practice, it's the two new categories that get skipped. Supply chain because "run npm audit" feels done while lockfiles, SBOMs, and signed builds stay unimplemented. And Mishandling of Exceptional Conditions because error paths get almost no review attention, yet they're exactly where security checks silently fail open.

Do I need to rework my 2021 security checklist?

Renumber it, don't rebuild it. Most of the mitigation work stays valid. The access control, crypto, injection, and auth code doesn't care which letter OWASP assigns it. The real work is adding the two new categories to your review process: supply chain verification (lockfiles, SBOM, signed artifacts) and error-path review (fail-closed defaults, no swallowed exceptions).

Where do I start if I can only fix one thing?

Broken Access Control. It's #1 for the fourth list running because it's the most common and the most impactful. An attacker doesn't need exotic tooling to change a userId in a request. Audit every endpoint that touches user data for an ownership check, and put middleware-level authorization in front of anything admin-only.