You open the OWASP Top 10, find ten names and wonder what you should change, on Monday, in the code in front of you. The short answer is that the list is not ten bugs: it is a map of families of weaknesses, and each family gathers dozens of CWEs, the codes the industry uses to give a name to a specific weakness. The current edition is 2025; in this guide you read it as a map, and you watch three of its items break and get fixed on a test application.
What the list is and how to read it
The official page defines it as an awareness document for people who build web applications, the result of a broad consensus on the most serious risks. It is not a compliance standard: it tells you where to look, not when you can stop looking. To get requirements you can check one by one there is the Application Security Verification Standard, now at version 5.0.0 (published on 30 May 2025), which is the document to put in a tender or in a review checklist.
The method is stated in the introduction: twelve categories were ranked using data submitted by people who do security testing, and two were promoted by the community survey, because data looks at the past while researchers are already finding tomorrow's problems. Each category is a container with a cap of 40 CWEs. The practical point is this: "Injection" contains both cross-site scripting (CWE-79, very frequent and of low impact) and SQL injection (CWE-89, rare and of high impact), and the page reports more than 30,000 CVEs for the first and more than 14,000 for the second. A category, then, is not a single defence, which is why the series covers them in separate articles.
What changed since 2021
In 2017 injection was in first place; since 2021 it has been access control, and in 2025 it stays there: two editions in a row, not four. The table summarises the positions, with the number of mapped CWEs read from the scoring table of each page.
| Position | 2025 category | Compared with 2021 | Mapped CWEs |
|---|---|---|---|
| A01 | Broken Access Control | Stays first; absorbs SSRF (CWE-918) | 40 |
| A02 | Security Misconfiguration | From fifth to second | 16 |
| A03 | Software Supply Chain Failures | New: widens Vulnerable and Outdated Components (A06:2021) | 6 |
| A04 | Cryptographic Failures | From second to fourth | 32 |
| A05 | Injection | From third to fifth | 37 |
| A06 | Insecure Design | From fourth to sixth | 39 |
| A07 | Authentication Failures | Stays seventh; loses "Identification" from the name | 36 |
| A08 | Software or Data Integrity Failures | Stays eighth | 14 |
| A09 | Security Logging and Alerting Failures | Stays ninth; "Alerting" replaces "Monitoring" | 5 |
| A10 | Mishandling of Exceptional Conditions | New | 24 |
Two reading notes. First, the OWASP pages are not consistent with each other on some counts: the introduction speaks of 5 CWEs for A03 and 38 for A05, while the scoring table says 6 and 37, and the A03 page itself reports an average incidence of 5.19% in the text and 5.72% in the table. For this reason I do not use those figures as an argument: they tell you the shape of the data, not a measurement to quote. Second, A03 has very few cases in the data (11 linked CVEs), but the highest average exploitability and impact scores in the list, and 50% of the survey respondents ranked it first: the difficulty of measuring it does not make it less serious.
An example of what A03 means in practice: on 23 September 2025 CISA, the US cybersecurity agency, published an alert about a self-replicating worm known as "Shai-Hulud", which had compromised more than 500 packages in the npm registry by stealing credentials from developers' environments and using them to publish infected versions of other packages. You can find how to defend your dependencies in article 06.
Three flaws demonstrated on a test application
The examples that follow run on applications written for the purpose, with fake data (example.test addresses), executed inside the process with Fastify 5.12.8's inject() (the version on the npm registry on 13 November 2026; you install it with npm install fastify@5.12.8). The method, described in Fastify's testing guide, simulates an HTTP request without opening a port and without starting a server: no request leaves the machine. Execution is on Node 24.11.0. Each script gives the same output both times it was run.
A01: reading someone else's profile (IDOR)
An insecure direct object reference (CWE-639, "Authorization Bypass Through User-Controlled Key", in the A01 family) arises when the identifier of a resource comes from the client and the server does not ask whether that client is entitled to see it. In the script the x-user header simulates an already authenticated user (in article 04 we build the real authentication). The /profile/:id route runs twice: without the check and with the check, which is the line with req.user.id !== id.
import Fastify from 'fastify';
const users = new Map([
['1', { id: '1', name: 'Alice', email: 'alice@example.test', role: 'user' }],
['2', { id: '2', name: 'Bruno', email: 'bruno@example.test', role: 'user' }],
['9', { id: '9', name: 'Admin', email: 'admin@example.test', role: 'admin' }],
]);
// Simulated authentication: the x-user header carries the id of whoever logged in.
// In a real application this is where a session is verified (see article 04).
function build({ checkOwner }) {
const app = Fastify();
app.addHook('onRequest', async (req, reply) => {
req.user = users.get(req.headers['x-user']);
if (!req.user) return reply.code(401).send({ error: 'not authenticated' });
});
app.get('/profile/:id', async (req, reply) => {
const { id } = req.params;
if (checkOwner && req.user.id !== id && req.user.role !== 'admin') {
return reply.code(403).send({ error: 'forbidden' });
}
const found = users.get(id);
return found ? found : reply.code(404).send({ error: 'not found' });
});
return app;
}
for (const checkOwner of [false, true]) {
const app = build({ checkOwner });
console.log(checkOwner ? '--- with the owner check' : '--- without the check');
for (const [who, what] of [['1', '1'], ['1', '2'], ['9', '2']]) {
const r = await app.inject({ url: `/profile/${what}`, headers: { 'x-user': who } });
console.log(`user ${who} asks for profile ${what}: ${r.statusCode} ${r.body}`);
}
}
// Output:
// --- without the check
// user 1 asks for profile 1: 200 {"id":"1","name":"Alice","email":"alice@example.test","role":"user"}
// user 1 asks for profile 2: 200 {"id":"2","name":"Bruno","email":"bruno@example.test","role":"user"}
// user 9 asks for profile 2: 200 {"id":"2","name":"Bruno","email":"bruno@example.test","role":"user"}
// --- with the owner check
// user 1 asks for profile 1: 200 {"id":"1","name":"Alice","email":"alice@example.test","role":"user"}
// user 1 asks for profile 2: 403 {"error":"forbidden"}
// user 9 asks for profile 2: 200 {"id":"2","name":"Bruno","email":"bruno@example.test","role":"user"}
Without the check, Alice (user 1) reads Bruno's profile by changing a number in the address: no sophisticated attack, only the URL. With the check the same request gets 403, while the administrator, who by role can read everything, gets through. One choice worth knowing: answering 403 confirms that profile 2 exists; if the existence itself is sensitive information, answer 404 in both cases. The typical flaw is not forgetting the check in one place, but remembering it in nine routes out of ten: this is why OWASP recommends writing the check once and reusing it, denying everything by default. In Angular a canActivate hides the page, but the API stays reachable with any client: the real rule lives on the server.
A10: when the error opens the door
The new 2025 category gathers badly handled abnormal behaviour, among which CWE-234 (a missing parameter that is not handled), CWE-476 (null dereference) and CWE-636, "failing open". The script has a verify function that, if the authorization header is missing, raises a TypeError on the line with split: a real exceptional condition, caused by whoever sends a request without the header. The only thing that changes is what the catch does.
import Fastify from 'fastify';
// Deliberately minimal check: the only valid token is the string "token-of-alice".
function verify(header) {
const token = header.split(' ')[1]; // TypeError if the header is missing
if (token !== 'token-of-alice') throw new Error('invalid token');
return { id: '1' };
}
function build(failClosed) {
const app = Fastify();
app.addHook('onRequest', async (req, reply) => {
try {
req.user = verify(req.headers.authorization);
} catch (e) {
if (failClosed) return reply.code(401).send({ error: 'authentication failed' });
// fail-open: the error is "handled" and the request goes on
}
});
app.get('/private', async (req) => ({ seen_by: req.user ? req.user.id : 'nobody' }));
return app;
}
const cases = [
['no header', {}],
['wrong token', { authorization: 'Bearer x' }],
['right token', { authorization: 'Bearer token-of-alice' }],
];
for (const closed of [false, true]) {
const app = build(closed);
console.log(closed ? '--- fail-closed' : '--- fail-open');
for (const [name, headers] of cases) {
const r = await app.inject({ url: '/private', headers });
console.log(`${name}: ${r.statusCode} ${r.body}`);
}
}
// Output:
// --- fail-open
// no header: 200 {"seen_by":"nobody"}
// wrong token: 200 {"seen_by":"nobody"}
// right token: 200 {"seen_by":"1"}
// --- fail-closed
// no header: 401 {"error":"authentication failed"}
// wrong token: 401 {"error":"authentication failed"}
// right token: 200 {"seen_by":"1"}
In the fail-open version the exception is swallowed and the request goes on: with no header and with a wrong token you get 200 on the private route, with seen_by: nobody, that is, the application answers an anonymous caller. In the fail-closed version the catch answers 401 and stops. Fail-open almost never needs code written on purpose: a catch that logs and carries on is enough, and it is a flaw that tests with a valid token never see, because they only try the happy path. Always try the case with no header, the one with a malformed header and the one with an expired credential.
A10 and A09: the error that says too much
CWE-209 describes error messages that contain sensitive information. The script throws an error with an SQL query and a fake connection string inside (test-secret); the error handler has two ways of answering, and the script uses three includes calls to check what ends up in the body.
import Fastify from 'fastify';
function build(production) {
const app = Fastify({ logger: false });
app.get('/orders', async () => {
const e = new Error('SQLITE_ERROR: no such column "total" in: SELECT total FROM orders');
e.dsn = 'postgres://app:test-secret@db.internal:5432/shop';
throw e;
});
app.setErrorHandler((err, req, reply) => {
if (!production) {
// harmful: everything the application knows ends up in the response
return reply.code(500).send({ message: err.message, stack: err.stack, dsn: err.dsn });
}
const ref = `E-${String(req.id).replace(/\D/g, '') || '0'}`;
console.log(`[internal log] ${ref} ${err.message}`); // the details stay in the log
return reply.code(500).send({ error: 'Internal error', reference: ref });
});
return app;
}
for (const production of [false, true]) {
const r = await build(production).inject({ url: '/orders' });
const body = r.json();
console.log(production ? '--- correct response' : '--- response that leaks data');
console.log('status:', r.statusCode, '| keys:', Object.keys(body).join(','));
console.log('contains SQL:', r.body.includes('SELECT'), '| contains credentials:', r.body.includes('test-secret'), '| contains stack:', r.body.includes(' at '));
}
// Output:
// --- response that leaks data
// status: 500 | keys: message,stack,dsn
// contains SQL: true | contains credentials: true | contains stack: true
// [internal log] E-1 SQLITE_ERROR: no such column "total" in: SELECT total FROM orders
// --- correct response
// status: 500 | keys: error,reference
// contains SQL: false | contains credentials: false | contains stack: false
The harmful version returns the message, the stack trace and the credentials: an attacker reconstructs table and column names and finds the database server. The correct version answers with a generic text and a reference (E-1) that support staff can look up in the internal log. Mind the log: writing the whole error object to the log would put the connection string back into the file; CWE-532 (sensitive information in log files) is in the A09 family, and that is why the script records only the message. A09 itself adds that a log with nobody watching it does not protect: the name changed to "Alerting" precisely to say that the alarm matters as much as the writing.
Where the rest of the list goes in the series
The other six topics have one article each, more room than a summary can give. The correspondence is this:
- XSS, CSRF and security headers (families A05 and A01): article 02.
- SQL injection and input validation (A05): article 03.
- Sessions, cookies and credentials (A07): article 04.
- OAuth, JWT and rate limiting (A01, A07): article 05.
- Dependencies and SBOM (A03): article 06.
- Hashing, encryption and tokens (A04): article 07.
Note that CSRF (CWE-352) sits in A01 and not in A05: for OWASP it is an access control flaw, because the server accepts an action without knowing whether the user wanted it. Article 08 closes the path, on how to bring these checks into the delivery pipeline. Left out of the series, by choice, are infrastructure configuration (A02) and secure design (A06), which need a whole system more than a script.
Frequently asked questions
Is the OWASP Top 10 enough to say an application is secure?
No. It is an awareness document: it says which families of weaknesses to consider, not which controls must exist. For verifiable requirements you use ASVS 5.0.0, which lists numbered controls that a reviewer can answer yes or no.
Which are the new categories in 2025?
Two: A03, Software Supply Chain Failures, which widens the old category on vulnerable components to the whole build and distribution chain, and A10, Mishandling of Exceptional Conditions, on the wrong handling of errors and abnormal conditions. In addition SSRF has merged into A01.
Why are the countermeasures in these examples not enough in production?
Because they are deliberately minimal: authentication is simulated, the data is fake and everything about infrastructure is missing. They serve to show the mechanism of the flaw and the shape of the fix, not to replace a review of your system.
What you can do now
You can read the 2025 Top 10 as a map of CWEs and not as ten bugs, you know what changed compared with 2021 and where OWASP's numbers do not match each other. You can reproduce on a test application an unauthorised access, an authentication check that opens when an error occurs and an error response that hands over credentials, and you can write the fix and the proof that it holds. The next step is the frontend: what the browser does for you and what it does not.
Sources
- OWASP Top 10:2025 and the ten cards from A01 to A10 - order, names, mapped CWEs, scoring tables, what is new in 2025 and the method (pages downloaded and read in full), consulted on 2026-11-13.
- OWASP Application Security Verification Standard - version 5.0.0 published on 30 May 2025, consulted on 2026-11-13.
- Widespread Supply Chain Compromise Impacting npm Ecosystem (CISA, 23 September 2025) - more than 500 compromised packages, how the worm works, consulted on 2026-11-13.
- Fastify: Testing - the
inject()method and simulation of HTTP requests without a server, consulted on 2026-11-13. - Fastify version:
npm view fastify version(5.12.8), 2026-11-13. The three scripts were run withnode24.11.0, twice, with identical output.

Commenti
Caricamento commenti...
Sign in to comment