Mert Özen Further With Every Line
Security Web Security (OWASP Top 10) — #20

OWASP Top 10: What Changed from 2021 to 2025 and Updating Old Documents

Mert Özen Nov 6, 2026 8 min 5 views
OWASP Top 10: What Changed from 2021 to 2025 and Updating Old Documents

SSRF joining A01, the components problem growing into Software Supply Chain Failures, Security Misconfiguration rising to second place, the new A10 Mishandling of Exceptional Conditions and three renames. We read the 2025 methodology with its numbers and write a small .NET tool that finds old and year-less category numbers and suggests their 2025 counterparts.

Version note: This post compares the OWASP Top 10:2021 with the OWASP Top 10:2025; the code was tested with .NET 10.

Guess First

Your team's threat model document contains this line: "Cover image proxy: A10 risk, allow-list the hosts." A new teammate who reads the document against the 2025 list looks up A10. What do they find? Is the line still correct?

They find A10:2025 Mishandling of Exceptional Conditions, that is, handling errors and exceptional situations badly. The author meant A10:2021 SSRF, and in 2025 SSRF moved into A01 Broken Access Control. The line is not wrong, but because it has no year it is now read wrong. In this post we see what changed from 2021 to 2025, and at the end we write a small tool that finds documents written with the old numbers. As throughout the series, only try these techniques on systems you own or have written permission to test.

The 2025 List at a Glance

The OWASP Top 10:2025 is the eighth edition of the list. It was first shared as a release candidate, and the final version was published at the end of December 2025. Unlike 2017, it has no official PDF; it exists only as a web document. Let's read the list together with the 2021 positions.

A01:2025 Broken Access Control is number one again, and SSRF now lives inside it. A02:2025 Security Misconfiguration rose from fifth to second. A03:2025 Software Supply Chain Failures is a new name that widens 2021's A06 Vulnerable and Outdated Components. A04:2025 Cryptographic Failures went from second to fourth, A05:2025 Injection from third to fifth, and A06:2025 Insecure Design from fourth to sixth.

A07:2025 Authentication Failures dropped "Identification" from its name. A08:2025 Software or Data Integrity Failures kept its position; its name now says "or" instead of "and". A09:2025 Security Logging and Alerting Failures says "Alerting" instead of "Monitoring". A10:2025 Mishandling of Exceptional Conditions is completely new.

The Movers: SSRF and Misconfiguration

SSRF (CWE-918) joining A01 is the natural continuation of the root-cause view that began in 2021. In SSRF the server sends a request, on the user's behalf, to a resource the user normally could not reach; at its core it is an access control problem. Putting a single-CWE category into the family it belongs to, instead of keeping it apart, makes the list more consistent. The defense did not change: an allow-list, checking the address after it is resolved, and turning off redirects. We saw that with HttpClient in post 4.

Security Misconfiguration rising to second place is no surprise either. Applications now consist of configuration as much as code: container settings, cloud resources, CORS and headers, per-environment settings files. XXE (CWE-611) is still in this category. That is why post 5 dealt early on with the developer exception page left on in Production and with missing security headers.

From Components to the Supply Chain

2021's "Vulnerable and Outdated Components" only looked at the components the application uses: is there a package with a known vulnerability? 2025's "Software Supply Chain Failures" widens the question: is the package itself malicious, is the build system trustworthy, did the published artifact really come from your source? In the four years between, account takeovers on npm, self-propagating worms and infiltration attempts carried out for years under a maintainer identity were the reason for this widening; we looked at these incidents in detail in post 6.

This category came from the community survey rather than the data, and it maps to only 6 CWEs. Few CWEs does not mean little importance: incidents like these are not the kind classic testing tools find, so they show up rarely in the data. On the .NET side the counterparts are NuGetAudit, lock files, SBOMs and, on the publishing side, provenance.

The New A10 and the Renames

A10:2025 Mishandling of Exceptional Conditions brings 24 CWEs under one roof. Examples: an error message that carries sensitive information to the client (CWE-209), a check that stays open on failure, that is, failing open (CWE-636), and NULL pointer dereference (CWE-476). OWASP's recommendations are familiar: catch the error where it happens, use a global exception handler as a safety net, roll back incomplete transactions, and monitor repeated errors. The IExceptionHandler and ProblemDetails examples in post 16 were the ASP.NET Core side of this category.

All three renames sharpen the scope. "Identification" was dropped because the category is about authentication itself. "and" became "or" because it is enough for the integrity of either the software or the data to break. "Monitoring" became "Alerting" because collecting logs and building a dashboard nobody looks at is not enough; someone has to be notified when the right event happens.

Methodology: Data and Survey

According to OWASP's 2025 introduction page, the data covers more than 2.8 million applications. About 220,000 CVEs were extracted, of which about 175,000 are mapped to a CWE in NVD. The ten categories hold 248 CWEs in total. The method follows the same logic as 2021: eight categories come from data and two from the community survey. This time the survey picks are A03 Software Supply Chain Failures and A09 Security Logging and Alerting Failures. The categories are again built around root causes rather than symptoms.

A small note when reading the numbers: the introduction page gives two different values for the number of CWEs analyzed, and when you add up the CWE counts on the category pages you get 249, not 248. These discrepancies don't change the value of the list; but if you use a number in a presentation, show its source.

Updating Documents with Old Numbers

We saw the numbers shift; now the practical question: how many places in your repo still have old numbers? A sample folder:

$ cat docs/threat-model.md
# Bookstore API threat model

Cover image proxy: A10:2021 SSRF, allow-list the hosts.
Password storage: see A02:2021 Cryptographic Failures.
Order endpoints: A01:2021, check ownership on every request.

$ cat docs/pr-checklist.md
- [ ] A03:2021 Injection: no string-built SQL
- [ ] A06 dependencies checked
- [ ] A05:2025 Injection reviewed

$ cat src/OrderEndpoints.cs
// OWASP A01:2021: the order must belong to the current user
// OWASP A09:2021: log denied access

A small console app that scans these files:

using System.Text.RegularExpressions;

// 2021 id -> 2025 category
Dictionary<string, string> from2021 = new()
{
    ["A01"] = "A01:2025 Broken Access Control",
    ["A02"] = "A04:2025 Cryptographic Failures",
    ["A03"] = "A05:2025 Injection",
    ["A04"] = "A06:2025 Insecure Design",
    ["A05"] = "A02:2025 Security Misconfiguration",
    ["A06"] = "A03:2025 Software Supply Chain Failures",
    ["A07"] = "A07:2025 Authentication Failures",
    ["A08"] = "A08:2025 Software or Data Integrity Failures",
    ["A09"] = "A09:2025 Security Logging and Alerting Failures",
    ["A10"] = "A01:2025 Broken Access Control (SSRF moved here)",
};

Regex categoryId = new(@"\bA(0[1-9]|10)(?::(\d{4}))?\b");
string root = args.Length > 0 ? args[0] : ".";
string[] extensions = [".md", ".cs"];

foreach (string file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
             .Where(f => extensions.Contains(Path.GetExtension(f)))
             .Order())
{
    string[] lines = File.ReadAllLines(file);
    for (int i = 0; i < lines.Length; i++)
    {
        foreach (Match match in categoryId.Matches(lines[i]))
        {
            string id = $"A{match.Groups[1].Value}";
            string year = match.Groups[2].Value;
            string? hint = year switch
            {
                "2021" => $"-> {from2021[id]}",
                // an id without a year means different things in different editions
                "" => "ambiguous: add the edition, e.g. A06:2025",
                _ => null,
            };
            if (hint is not null)
            {
                Console.WriteLine($"{Path.GetRelativePath(root, file)}:{i + 1}  {match.Value}  {hint}");
            }
        }
    }
}
$ dotnet run -- ../sample
docs/pr-checklist.md:1  A03:2021  -> A05:2025 Injection
docs/pr-checklist.md:2  A06  ambiguous: add the edition, e.g. A06:2025
docs/threat-model.md:3  A10:2021  -> A01:2025 Broken Access Control (SSRF moved here)
docs/threat-model.md:4  A02:2021  -> A04:2025 Cryptographic Failures
docs/threat-model.md:5  A01:2021  -> A01:2025 Broken Access Control
src/OrderEndpoints.cs:1  A01:2021  -> A01:2025 Broken Access Control
src/OrderEndpoints.cs:2  A09:2021  -> A09:2025 Security Logging and Alerting Failures

The tool skipped the line already written against 2025 (A05:2025 Injection reviewed) and flagged the bare A06 as ambiguous. That second output is the most valuable: in 2021 A06 meant components, in 2025 it means Insecure Design. The tool does not change the files, it only suggests; some mappings need a human decision. For example, when moving the A10:2021 line to A01:2025, keeping the word "SSRF" in the text makes life easier for the reader. 2017-style single-digit numbers (A4) don't match this regex; catching them is one of the exercises.

Common Pitfalls

The first is writing numbers without a year. As you saw, the same "A06" means two different things in two editions. The rule is simple: in documents, PR templates, issue labels and code comments, always write it as A06:2025.

The second is assuming the content disappears when a category moves. SSRF moved into A01, but its defense is the same; not having its own item does not mean it leaves your checklist.

The third is assuming the mapping goes one way. A10:2025 has no counterpart in 2021. If you consider the old document "complete" and forget to add the new category, exception handling never gets discussed.

The fourth is forgetting old numbers in tool and report settings. Static analysis rules, pentest report templates and risk registers can also carry Top 10 numbers; looking only at Markdown is not enough.

Checklist

Scan the documents and code comments in your repo; convert 2021 numbers to their 2025 counterparts and add a year to bare numbers. When moving lines that mention SSRF under A01:2025, keep the word "SSRF". Add a section for A10:2025 to your checklist: a global exception handler, fail closed, rolling back incomplete operations. Widen the A03 heading beyond "up-to-date packages" to cover the build and release chain. Under A09, check alerting as well as log collection. If you quote numbers, show their source.

A Small Experiment

★☆☆ Run the tool on one of your own projects and note how many lines it finds. Add .yml and .json to the extension list and try again.

★★☆ Extend the regex to catch 2017-style numbers too (A4:2017, A7) and write a separate mapping dictionary for 2017 numbers. Use the mappings in this post and in posts 18 and 19 as your source.

★★★ Add a --check option to the tool: if it finds any old or bare number, it should return a non-zero exit code. Then think of it as a step that runs in CI; can you stop a new document with an old number from being merged?

In the next and final post of the series we step outside the Top 10: we look at OWASP's other lists, API Security Top 10, Top 10 for LLM Applications, ASVS and the Cheat Sheet Series, and talk about how the series continues from here.