OWASP Top 10 2025 Walkthrough: Stop Copying Security Checklists — Here’s What Actually Fixes Things

Every few years OWASP updates its Top 10 list and the internet floods with the same recycled blog posts — a numbered list, a one-sentence description of each flaw, a vague "validate your inputs" recommendation, and maybe a link to the official docs. Useless.

This isn’t that article.

The OWASP Top 10 2025 carries real shifts from the 2021 edition: broken access control is still the undisputed champion of production incidents, but the threat model around supply chain, LLM integrations, and server-side forgery has matured. More importantly, developers keep getting these wrong for concrete, fixable reasons — not because they’re careless, but because the explanations they read are abstract to the point of uselessness.

What follows is a straight walkthrough of each category with vulnerable code, the actual fix, and the gotcha that’ll bite you if you stop halfway.

The official project lives at https://owasp.org/www-project-top-ten/.


A01 — Broken Access Control

Still number one. Has been for years. Won’t drop anytime soon.

The classic mistake isn’t that teams skip authorization — it’s that they implement it only at the route level and forget that the data itself needs checking.

Vulnerable:

# FastAPI — checks that user is logged in, nothing more
@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: int, current_user: User = Depends(get_current_user)):
    return db.query(Invoice).filter(Invoice.id == invoice_id).first()

User 42 can fetch invoice 99 belonging to user 17. The JWT was valid. The route was "protected". The data was wide open.

Fixed:

@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: int, current_user: User = Depends(get_current_user)):
    invoice = db.query(Invoice).filter(
        Invoice.id == invoice_id,
        Invoice.owner_id == current_user.id   # ownership check
    ).first()
    if not invoice:
        raise HTTPException(status_code=404)  # 404, not 403 — don't leak existence
    return invoice

Gotcha: Returning 403 Forbidden when a resource exists but the user can’t see it leaks information. Always return 404 for unauthorized access to specific resources. Your authorization layer should be invisible from the outside.

Production tip: Write a policy enforcement layer that runs as middleware, not inside individual route handlers. One missed owner_id check in a handler means a bypass. Centralized policy means one place to audit.


A02 — Cryptographic Failures

Previously called "Sensitive Data Exposure" — the rename is accurate. The problem isn’t just that data gets exposed, it’s that the crypto protecting it is broken from the start.

The most common failure in 2025 isn’t using MD5 for passwords (hopefully). It’s storing JWTs with sensitive claims unencrypted, using ECB mode for AES, and generating symmetric keys with insufficient entropy.

Vulnerable:

import hashlib
# MD5 for password hashing — criminally fast, rainbow-table friendly
hashed = hashlib.md5(password.encode()).hexdigest()

Fixed:

import bcrypt
# bcrypt with cost factor 12 — adaptive, slow by design
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))

For tokens carrying PII: use JWE (JSON Web Encryption), not just JWS (signed). A signed JWT is still readable by anyone who intercepts it — the signature only prevents tampering, not reading.

Gotcha: Developers confuse "signed" with "encrypted". Your JWT with {"email": "[email protected]", "role": "admin"} is Base64-encoded, not secret. Anyone with the token can decode the payload. If you’re putting sensitive data in tokens, encrypt them.

Production tip: TLS in transit is table stakes but check your cipher suite. Disable TLS 1.0/1.1 and weak ciphers in your nginx or Caddy config. ssl_protocols TLSv1.2 TLSv1.3; is the minimum in 2025.


A03 — Injection

SQL injection has been on this list since 2003. It’s still here because ORMs create a false sense of security, and raw queries still appear the moment a developer needs "just one quick filter."

Vulnerable:

// Express + pg — raw string interpolation
const result = await pool.query(
  `SELECT * FROM users WHERE username = '${req.body.username}'`
);

Fixed:

// Parameterized query — the driver handles escaping
const result = await pool.query(
  'SELECT * FROM users WHERE username = $1',
  [req.body.username]
);

Injection in 2025 isn’t limited to SQL. OS command injection through shell calls, LDAP injection in enterprise auth flows, and prompt injection in LLM-integrated backends are all in scope for this category now.

Prompt injection (new surface):

# Vulnerable: user input goes directly into LLM system prompt context
response = llm.complete(f"Summarize this document: {user_input}")

# Better: strict input sanitization + output validation layer
sanitized = strip_injection_patterns(user_input)
response = llm.complete(f"Summarize the following text, nothing else:\n\n{sanitized}")
validate_response_format(response)

Gotcha: ORMs don’t protect you from raw expressions. Django’s Model.objects.raw(), SQLAlchemy’s text(), and Mongoose’s $where operator all accept unsanitized input. The parameterized interface is always available — use it.


A04 — Insecure Design

This is the category that makes developers uncomfortable because it can’t be fixed with a patch. The vulnerability was written into the architecture before a single line of code existed.

Classic examples: password reset flows that use predictable tokens, multi-tenant systems that use sequential integer IDs, business logic that trusts client-supplied prices.

The token generation problem:

import random
import time

# Vulnerable: seeded with time — predictable within a narrow window
def generate_reset_token():
    random.seed(int(time.time()))
    return str(random.randint(100000, 999999))
import secrets

# Fixed: cryptographically secure random
def generate_reset_token():
    return secrets.token_urlsafe(32)

Gotcha: A six-digit numeric token has 900,000 possibilities. With no rate limiting on the reset endpoint, that’s brute-forceable in minutes. Token length matters, but rate limiting and single-use enforcement matter just as much.

Production tip: Treat threat modeling as a design step, not an afterthought. Before building a feature, spend 20 minutes asking: "What happens if a user lies about this input? What if they replay this request? What if they enumerate this endpoint?" STRIDE is a decent framework to formalize the process.


A05 — Security Misconfiguration

The most democratized vulnerability on the list. Exposed S3 buckets, default admin credentials, stack traces leaking internal paths, overly permissive CORS — this is where sloppy ops meets production.

CORS misconfiguration:

// Vulnerable: reflects origin from request header — any site can read your API
app.use(cors({ origin: req.headers.origin, credentials: true }));
// Fixed: explicit allowlist
const ALLOWED_ORIGINS = ['https://app.yoursite.com', 'https://admin.yoursite.com'];
app.use(cors({
  origin: (origin, callback) => {
    if (!origin || ALLOWED_ORIGINS.includes(origin)) callback(null, true);
    else callback(new Error('Not allowed by CORS'));
  },
  credentials: true
}));

Docker security misconfiguration:

# Vulnerable compose snippet — runs as root, binds everywhere
services:
  api:
    image: myapp:latest
    ports:
      - "0.0.0.0:8080:8080"
# Better
services:
  api:
    image: myapp:latest
    user: "1001:1001"          # non-root user
    read_only: true             # immutable container filesystem
    ports:
      - "127.0.0.1:8080:8080"  # bind only to localhost
    security_opt:
      - no-new-privileges:true

Gotcha: Debug endpoints (/debug, /actuator, /phpinfo.php) enabled in production are a misconfiguration, not a feature toggle. Rotate your environment variables, not your logs, when you find one exposed.


A06 — Vulnerable and Outdated Components

This category exploded in relevance after Log4Shell and the XZ Utils backdoor. The problem isn’t just that you’re running old packages — it’s that you have no inventory of what you’re running.

Production tip: Generate and maintain an SBOM (Software Bill of Materials). Tools like syft, trivy, and grype make this manageable.

# Scan your Docker image for CVEs before pushing
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest

# Generate an SBOM for your Go/Node/Python project
syft . -o spdx-json > sbom.json

Wire this into your CI pipeline. A build that introduces a HIGH CVE should fail.

Gotcha: Pinning exact dependency versions in package-lock.json or requirements.txt doesn’t protect you from transitive dependencies. You locked requests==2.28.0 but didn’t lock urllib3 underneath it. Tools like pip-audit and npm audit catch transitive CVEs.


A07 — Identification and Authentication Failures

Renamed from "Broken Authentication" in 2021, and the scope is broader now. It covers everything from weak passwords to session fixation to credential stuffing at scale.

The failure mode most people miss: not invalidating sessions server-side on logout.

Vulnerable:

// Logout just deletes the cookie client-side — the JWT is still valid
app.post('/logout', (req, res) => {
  res.clearCookie('token');
  res.json({ ok: true });
});

If the token is stateless (JWT), you need a server-side blocklist for revocation to work.

Fixed:

# Redis-backed token blocklist
def logout(token: str, redis_client):
    claims = decode_jwt(token)
    ttl = claims['exp'] - int(time.time())  # time until natural expiry
    if ttl > 0:
        redis_client.setex(f"revoked:{claims['jti']}", ttl, "1")

def verify_token(token: str, redis_client):
    claims = decode_jwt(token)
    if redis_client.exists(f"revoked:{claims['jti']}"):
        raise AuthError("Token revoked")
    return claims

Gotcha: Storing session tokens in localStorage makes them accessible to JavaScript — which means any XSS on your domain can steal them. Use HttpOnly; Secure; SameSite=Strict cookies for session tokens. SameSite=Lax is acceptable if you need cross-origin GET requests, but SameSite=None without a strong reason is asking for CSRF trouble.


A08 — Software and Data Integrity Failures

This is the supply chain category, and it’s directly responsible for the SolarWinds and XZ Utils incidents making the news. It covers two related problems: deserializing untrusted data without verification, and pulling dependencies without checking their integrity.

Insecure deserialization:

import pickle

# Vulnerable: pickle can execute arbitrary code during deserialization
data = pickle.loads(user_supplied_bytes)

Never deserialize pickle, Java’s native serialization, or PHP’s unserialize() from untrusted sources. Use JSON or MessagePack with a strict schema validator.

import json
import jsonschema

SCHEMA = {
    "type": "object",
    "properties": {
        "user_id": {"type": "integer"},
        "action": {"type": "string", "enum": ["read", "write"]}
    },
    "required": ["user_id", "action"],
    "additionalProperties": False
}

data = json.loads(user_supplied_bytes)
jsonschema.validate(data, SCHEMA)  # raises on unexpected fields

CI integrity — verify your supply chain:

# GitHub Actions — pin actions to a full commit SHA, not a tag
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
  # Tags are mutable. Commit SHAs aren't.

Gotcha: Subresource Integrity (SRI) hashes on CDN-loaded scripts are still widely skipped. If you load jQuery or any library from a CDN without an integrity attribute, a compromised CDN can serve malicious JS to every user of your app.


A09 — Security Logging and Monitoring Failures

You can have the other nine categories fully hardened and still get owned silently for months. This category is about detection, not prevention — and it’s the one most development teams treat as an ops problem and never touch.

The baseline you need:

  • Log authentication events (success, failure, lockout) with IP and user agent
  • Log authorization failures — these are your attack signal
  • Log sensitive operations (password changes, privilege escalations, bulk exports)
  • Never log passwords, tokens, or PII
import structlog
import hashlib

log = structlog.get_logger()

def login_attempt(username: str, ip: str, success: bool):
    log.info(
        "auth.login",
        username=username,
        ip=ip,
        success=success,
        # hash the IP for GDPR compliance if needed
        ip_hash=hashlib.sha256(ip.encode()).hexdigest()[:16]
    )

def access_denied(user_id: int, resource: str, ip: str):
    log.warning(
        "authz.denied",
        user_id=user_id,
        resource=resource,
        ip=ip
    )

Gotcha: Structured logging (JSON lines) is useless if nobody reads it. Point your logs at a real alerting system. Five consecutive authz.denied events from the same IP in 60 seconds is a scan in progress. Five hundred auth.login failures against different usernames is credential stuffing. Your logs should page someone.

Production tip: Separate your security event log from your application log. Make the security log append-only at the filesystem or storage level. An attacker who pops your app shouldn’t be able to erase evidence.


A10 — Server-Side Request Forgery (SSRF)

SSRF moved onto the list in 2021 and isn’t going anywhere. The cloud made it catastrophically worse: AWS/GCP/Azure metadata endpoints at 169.254.169.254 hand out IAM credentials to anyone who can reach them from inside the instance.

Vulnerable:

import requests

@app.route('/fetch-preview')
def fetch_preview():
    url = request.args.get('url')
    # No validation — attacker sends http://169.254.169.254/latest/meta-data/
    response = requests.get(url, timeout=5)
    return response.text

Fixed:

import ipaddress
import urllib.parse

BLOCKED_RANGES = [
    ipaddress.ip_network('169.254.0.0/16'),  # link-local / metadata
    ipaddress.ip_network('10.0.0.0/8'),       # RFC1918
    ipaddress.ip_network('172.16.0.0/12'),
    ipaddress.ip_network('192.168.0.0/16'),
    ipaddress.ip_network('127.0.0.0/8'),      # loopback
]

def is_safe_url(url: str) -> bool:
    parsed = urllib.parse.urlparse(url)
    if parsed.scheme not in ('http', 'https'):
        return False
    try:
        import socket
        ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
        return not any(ip in net for net in BLOCKED_RANGES)
    except Exception:
        return False

@app.route('/fetch-preview')
def fetch_preview():
    url = request.args.get('url', '')
    if not is_safe_url(url):
        abort(400)
    response = requests.get(url, timeout=5, allow_redirects=False)
    return response.text

Gotcha: DNS rebinding bypasses naive IP blocklist checks. You resolve the hostname, it returns a public IP, passes your check, then the DNS TTL expires and the hostname now resolves to 169.254.169.254. The fix: pin the resolved IP and use it for the actual request, don’t re-resolve mid-request. The allow_redirects=False in the example above prevents redirect chains to internal hosts.


Integrating OWASP into your workflow

Reading the list is easy. The hard part is making these checks automatic so you don’t need to remember them under deadline pressure.

A practical DevSecOps baseline that doesn’t require a dedicated security team:

# .github/workflows/security.yml
name: Security Checks
on: [pull_request]

jobs:
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - name: Run Semgrep (SAST)
        uses: returntocorp/semgrep-action@v1
        with:
          config: p/owasp-top-ten

  dependency-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - name: Run Trivy
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: fs
          exit-code: 1
          severity: HIGH,CRITICAL

Semgrep’s p/owasp-top-ten ruleset catches a surprisingly high percentage of the code-level issues above. It won’t catch architectural problems (A04, A09), but it’ll stop developers from shipping obvious injection or crypto failures.


Where to go from here

The OWASP Top 10 is a starting point, not a finish line. Once you’ve got these basics covered:

  • OWASP ASVS (Application Security Verification Standard) — a detailed checklist for security requirements, tiered by risk level. More actionable than the Top 10 for teams doing structured reviews.
  • OWASP WSTG (Web Security Testing Guide) — the hands-on companion, covers how to test for each vulnerability rather than just what they are.
  • OWASP LLM Top 10 — if your application integrates AI/LLM features, this is mandatory reading. Prompt injection, model inversion, and insecure plugin design aren’t covered by the main Top 10 yet.

The honest takeaway: most production breaches aren’t exotic zero-days. They’re A01, A03, or A05 — boring, well-documented vulnerabilities that survived code review because everyone assumed someone else checked. The fixes are rarely complex. The discipline to apply them consistently is the hard part.

Leave a comment

👁 Views: 2,290 · Unique visitors: 1,647