OWASP's Top 10 has been the reference list every application security program cites since the project's first release in 2003, and the canonical URL at owasp.org/Top10 now redirects to the 2025 edition — the first refresh since 2021. The reshuffle is bigger than a version bump: Broken Access Control holds the #1 slot it also held in 2021, "Vulnerable and Outdated Components" is reframed and widened into Software Supply Chain Failures at #3, and a brand-new category, Mishandling of Exceptional Conditions, debuts at #10 to capture the error-handling bugs that don't fit neatly into injection or misconfiguration. OWASP describes the document itself as "a standard awareness document for developers and web application security" — not a compliance checklist, but a prioritized map of where real applications actually get breached. This post walks through all ten 2025 categories in order, with a minimal vulnerable snippet and a minimal fix for each, so the list stops being an abstract ranking and starts being something you can check your own diff against. The code is intentionally small — a few lines per side — because the point isn't a full remediation guide, it's pattern recognition: knowing the shape of each bug well enough to spot it in review.
What changed between the 2021 and 2025 lists?
The headline change is Broken Access Control staying at #1, the same position it held in the 2021 list, reflecting that authorization bugs — not injection — remain the most commonly reported flaw class in real-world assessments. The second major change is structural: 2021's "Vulnerable and Outdated Components" is now Software Supply Chain Failures at #3, explicitly widening scope from "your dependency has a known CVE" to the broader set of build-pipeline, registry, and provenance risks that dependency confusion and compromised-maintainer incidents have made prominent over the past several years. Third, Insecure Design still holds its own slot at #6, keeping OWASP's 2021-era argument that some vulnerabilities are architectural, not just implementation bugs. Finally, Mishandling of Exceptional Conditions is entirely new at #10 — a category for the failure paths, not the happy paths, of an application. Together these changes tell developers where OWASP's data says review effort is currently most underinvested.
How do you prevent Broken Access Control (A01:2025)?
Broken Access Control happens when an application checks that a user is authenticated but never verifies they're authorized for the specific object they're requesting — the classic insecure direct object reference. The fix is to scope every lookup to the requesting user, not just to check a role flag in isolation.
Vulnerable:
@app.get("/invoices/<invoice_id>")
def get_invoice(invoice_id):
return db.query(f"SELECT * FROM invoices WHERE id = {invoice_id}")
Fixed:
@app.get("/invoices/<invoice_id>")
def get_invoice(invoice_id):
return db.query(
"SELECT * FROM invoices WHERE id = %s AND owner_id = %s",
(invoice_id, current_user.id),
)
Any authenticated user could change the invoice_id in the URL and read another tenant's invoice in the vulnerable version — a pattern that has driven a large share of the disclosed API breaches tracked by bug bounty platforms in recent years, because the request itself looks completely legitimate to a server that never checks ownership.
How do you prevent Security Misconfiguration (A02:2025)?
Security Misconfiguration covers everything from default credentials left in place to overly permissive cloud storage to debug modes shipped to production. It's less a single bug pattern than a class of "the framework was capable of being secure and nobody turned that on."
Vulnerable:
app.config["DEBUG"] = True
app.run(host="0.0.0.0")
Fixed:
app.config["DEBUG"] = os.environ.get("ENV") != "production"
app.run(host="127.0.0.1", port=8080)
A debug console left enabled in production is a well-known real-world path to remote code execution in several popular web frameworks' interactive debuggers, because the same console that helps you inspect a stack trace locally will happily evaluate arbitrary Python from anyone who can reach the error page. Binding to a loopback interface behind a reverse proxy, rather than 0.0.0.0, is a small change that removes an entire class of direct-exposure misconfigurations.
What is a Software Supply Chain Failure (A03:2025)?
This category widens 2021's "vulnerable components" framing to include the whole path a dependency travels before it lands in your build — unpinned versions, unverified package sources, and compromised build infrastructure, not just outdated CVEs. Dependency confusion, where an internal package name is squatted on a public registry and picked up automatically by a misconfigured build, is the pattern researcher Alex Birsan demonstrated across dozens of companies in 2021, several of which paid bug bounties as a result.
Vulnerable:
# requirements.txt
requests
internal-auth-lib
Fixed:
# requirements.txt — pinned, with a private index configured
requests==2.32.3
internal-auth-lib==4.1.0 --index-url https://pkgs.internal.example.com/simple/
Pinning versions stops silent upgrades to a compromised release; explicitly routing internal package names to a private index closes the confusion attack, since the resolver never falls back to the public registry for a name it should already trust.
How do you prevent Cryptographic Failures (A04:2025)?
Cryptographic Failures cover data exposed because it was never encrypted, or was encrypted with a broken primitive — MD5 or SHA-1 for password storage, ECB mode for block ciphers, or hardcoded keys.
Vulnerable:
import hashlib
password_hash = hashlib.md5(password.encode()).hexdigest()
Fixed:
from argon2 import PasswordHasher
password_hash = PasswordHasher().hash(password)
MD5 and SHA-1 are fast, general-purpose hash functions — precisely the wrong property for password storage, since speed is what lets an attacker brute-force a stolen hash dump. A memory-hard, purpose-built password hash like Argon2 (the winner of the 2015 Password Hashing Competition) is deliberately slow and resistant to GPU cracking, which is the entire point.
How do you prevent Injection (A05:2025)?
Injection remains the category most developers already recognize: untrusted input reaching an interpreter — SQL, shell, LDAP, or a template engine — without separation between code and data.
Vulnerable:
os.system(f"convert {filename} output.png")
Fixed:
subprocess.run(["convert", filename, "output.png"], shell=False)
Passing arguments as a list rather than an interpolated string means the shell never re-parses filename, so a value like ; rm -rf / is treated as a single literal filename argument instead of a chained command. The same principle — parameterization over string-building — is what fixes SQL injection with prepared statements and command injection with argument arrays alike.
Why does Insecure Design (A06:2025) exist as its own category?
Insecure Design captures flaws that no amount of clean implementation fixes, because the flaw is in the architecture itself — a password-reset flow with no rate limit, or a multi-tenant system that shares one database schema with row-level trust instead of hard tenant boundaries. OWASP's argument, carried forward from 2021, is that threat modeling before writing code catches these; a code review after the fact usually can't, since the code correctly implements a design that was never safe.
Vulnerable:
POST /reset-password { "email": "victim@example.com" }
# no rate limit, no CAPTCHA, unlimited attempts per minute
Fixed:
POST /reset-password { "email": "victim@example.com" }
# rate-limited to 5/hour per email + per IP, with exponential backoff
How do you prevent Authentication Failures (A07:2025)?
Authentication Failures cover weak credential policies, missing multi-factor authentication, and session tokens that don't expire or rotate.
Vulnerable:
session["user_id"] = user.id
# session cookie never expires, no MFA offered
Fixed:
session["user_id"] = user.id
session.permanent = True
app.permanent_session_lifetime = timedelta(hours=8)
require_mfa_if_enrolled(user)
Credential-stuffing attacks, which reuse passwords leaked from unrelated breaches, are defeated far more reliably by MFA than by password-complexity rules alone — a point security teams have made consistently since large-scale password-reuse breaches became common in the mid-2010s.
How do you prevent Software or Data Integrity Failures (A08:2025)?
This category covers trusting data or code without verifying it came from where you think it did — unsigned software updates, CI/CD pipelines that pull unverified artifacts, or deserializing objects from an untrusted source.
Vulnerable:
import pickle
obj = pickle.loads(request.data)
Fixed:
import json
obj = json.loads(request.data)
pickle.loads on attacker-controlled bytes can execute arbitrary code during deserialization, because Python's pickle format can encode instructions to call arbitrary functions — a risk the Python documentation itself explicitly warns about. Switching to a data-only format like JSON removes the code-execution path entirely; if you need to deserialize custom objects, a schema-validated library is a safer middle ground than raw pickle.
How do you prevent Security Logging and Alerting Failures (A09:2025)?
Without sufficient logging, a breach can go undetected for months, and without alerting, a detected anomaly never reaches a human who can act on it. This category is about auditability of security-relevant events specifically — logins, permission changes, and failed authorization checks — not general application logging.
Vulnerable:
@app.post("/admin/grant-role")
def grant_role():
user.role = request.json["role"]
db.commit()
Fixed:
@app.post("/admin/grant-role")
def grant_role():
old_role = user.role
user.role = request.json["role"]
db.commit()
audit_log.record("role_change", actor=current_user.id,
target=user.id, old=old_role, new=user.role)
A privilege-escalation bug that isn't logged is a privilege-escalation bug your incident responders can't reconstruct after the fact.
What is Mishandling of Exceptional Conditions (A10:2025)?
This is the new category for 2025, and it targets what happens on the error path rather than the success path: exceptions that leak stack traces to end users, error handlers that fail open instead of closed, and retry logic that silently swallows a security-relevant failure.
Vulnerable:
try:
verify_signature(payload, signature)
except Exception:
pass # continue processing regardless
Fixed:
try:
verify_signature(payload, signature)
except Exception:
logger.warning("signature verification failed")
abort(401)
Catching an exception and continuing as if verification succeeded is a fail-open pattern — precisely the shape of bug this new category exists to name and catch in review, since it's easy to write during a rushed fix for an unrelated crash and easy to miss without an explicit checklist item for it.
How Does Safeguard Help With OWASP Top 10 Coverage?
Safeguard's first-party SAST engine traces source-to-sink dataflow across JavaScript/TypeScript, Python, and Java, and maps every finding to its CWE and OWASP category — so an A01 access-control gap or an A05 injection sink shows up in your findings view with the exact dataflow trace, not just a line number. Safeguard's DAST engine complements this by testing running, verified targets for the runtime-visible categories — missing security headers and misconfigurations that map to A02, and injection classes reachable only once an app is deployed — under strict scope and safety-mode controls, never against unverified targets. Because SAST, DAST, and SCA findings share one correlation model, a DAST-confirmed A05 injection finding gets linked back to the exact SAST-identified sink that caused it, so the fix ships to the right function on the first try instead of a general "your app has SQL injection somewhere" ticket.