The OWASP Top 10:2025 added a category that most teams will recognise from their own code: A10, Mishandling of Exceptional Conditions. It covers what happens when something goes wrong: a dependency is down, a value is missing, a step in the middle of a transaction fails. The risk is not the failure itself but what the application does next.
What the category says
OWASP describes A10 as a new category covering improper error handling, logical errors and failing open. Its description says programs "fail to prevent, detect, and respond to unusual and unpredictable situations", which can lead to crashes, unexpected behavior and vulnerabilities.
The page maps 24 CWEs to it. A few of them read like code-review findings:
- CWE-636, Not Failing Securely ('Failing Open')
- CWE-209, Generation of Error Message Containing Sensitive Information
- CWE-390, Detection of Error Condition Without Action
- CWE-396, Declaration of Catch for Generic Exception
- CWE-252, Unchecked Return Value
The data table lists a maximum incidence rate of 20.67% and an average of 2.95%, with 769,581 occurrences and 3,416 CVEs across the mapped CWEs. Its prevention guidance, paraphrased, is: handle errors where they occur, keep a global exception handler as a safety net, fail closed, and when a multi-step transaction fails partway "roll back every part of the transaction and start again".
The three examples below turn three of those sentences into code that can be run.
1. The check that fails open
Authorization usually depends on something that can fail: a policy engine, a database, a remote API. The first version catches the error, logs it, and carries on with a permissive default:
async function guardFailOpen(req) {
let allowed = true; // permissive default
try { allowed = await permissionService.canRead(req.user, req.docId); }
catch (e) { console.error('permission check failed', e.message); } // logged, then ignored
return allowed ? { status: 200 } : { status: 403 };
}
The second version makes the decision explicit. Any error, and any answer that is not exactly true, is a denial:
async function guardFailClosed(req) {
try {
const answer = await permissionService.canRead(req.user, req.docId);
return answer === true ? { status: 200 } : { status: 403 };
} catch (e) {
console.error('permission check failed', e.message);
return { status: 503, body: 'temporarily unavailable' }; // not a grant
}
}
Run for a user who has no access to the document (bob asking for doc-1):
| Policy service | Fail-open guard | Fail-closed guard |
|---|---|---|
| Up | 403 | 403 |
| Down | 200 | 503 |
While the dependency is healthy, both versions behave the same, so a normal test suite passes. The difference only exists during the failure. The error was logged in both versions, which is the point of CWE-390: noticing an error condition without acting on it is not handling it.
2. The transfer that fails halfway
OWASP's third example scenario is an interrupted multi-step transfer: debit, credit, log. Three statements, with the third failing (here, an audit insert that violates a NOT NULL constraint):
// Version 1: three statements, no transaction
function transferNaive(db, amount, breakAudit) {
db.prepare("UPDATE accounts SET balance = balance - ? WHERE id='A'").run(amount);
db.prepare("UPDATE accounts SET balance = balance + ? WHERE id='B'").run(amount);
db.prepare('INSERT INTO audit (entry) VALUES (?)').run(breakAudit ? null : `A->B ${amount}`);
}
// Version 2: one transaction; any error rolls every part back
function transferAtomic(db, amount, breakAudit) {
db.exec('BEGIN');
try {
db.prepare("UPDATE accounts SET balance = balance - ? WHERE id='A'").run(amount);
db.prepare("UPDATE accounts SET balance = balance + ? WHERE id='B'").run(amount);
db.prepare('INSERT INTO audit (entry) VALUES (?)').run(breakAudit ? null : `A->B ${amount}`);
db.exec('COMMIT');
} catch (e) { db.exec('ROLLBACK'); throw e; }
}
State before: account A has 100, B has 0, the audit table is empty. After a transfer of 40 where the audit step fails:
| Version | A | B | Audit rows | Result |
|---|---|---|---|---|
| Naive | 60 | 40 | 0 | Money moved, nothing recorded |
| Atomic | 100 | 0 | 0 | Nothing changed |
Both versions raise the same error. Only one of them leaves a consistent system behind. In the naive version, a retry by the caller would debit the account a second time.
3. The error message that explains too much
CWE-209 and OWASP's second scenario describe database errors that reveal system details, which an attacker can use as reconnaissance. A handler that returns the exception's stack to the client does exactly that:
function handleLeaky(req, run) { try { return { status: 200, body: run() }; } catch (e) { return { status: 500, body: e.stack }; } }
function handleSafe(req, run) {
try { return { status: 200, body: run() }; }
catch (e) {
const id = randomUUID();
console.error(JSON.stringify({ id, msg: e.message })); // details stay on the server
return { status: 500, body: `Something went wrong. Reference: ${id}` };
}
}
With a simulated database error, the leaky handler returned a four-line body that began Error: SQLITE_ERROR: no such column: users.pw_hash at /srv/app/db/orders.js:88: a table, a column name and a file path. The safe handler returned Something went wrong. Reference: e074ed12-…, and the same identifier appears in the server log, so a support request can still be traced.
What the three have in common
Each failure was handled in the sense that no exception escaped. The defect was in the decision made after the failure: a permissive default, a half-applied state, a verbose reply. That is why none of them is found by a test where everything works. They need a test where something breaks.
A test list for your own service
Each row is something an authorised tester arranges in an environment they own, with the expected result.
| Arrange | Expected |
|---|---|
| Make the authorization or policy dependency unreachable, then request a protected resource as a user without access | Denied (or unavailable), never granted |
| Make the dependency return garbage (null, an empty object, a timeout) | Denied |
| Fail the last step of a multi-step operation | No partial state; a retry does not repeat completed steps |
| Send a request with a missing required parameter and with an extra parameter | 4xx with a generic message, no stack, path or query text |
| Force an internal error | A generic body with a reference id; the detail is only in the server log |
| Repeat the same failing request many times | Rate limited or summarised in logs, without exhausting resources |
OWASP's first scenario is the last row's concern: an upload handler that catches an exception but does not release its resources, so repeated failures exhaust them.
Practise the review
Breachloom has an exercise on the information-disclosure side, SQL error disclosure in search, with a matching chapter, A database error message is a disclosure channel of its own. For the half-applied state, there is a fix-the-code mission, Atomic Coupon, where the repair must pass a regression test, and a general article, Secure Coding Exercises: Verify the Fix, Not Just the Attack. The chapters and the free IDOR case need no account; the full set of challenges is part of a paid plan.
Which dependency of your own service, if it went down right now, would turn a check into a pass? Answering that by switching it off in a test environment is faster than reading the code for it.
Sources
- OWASP Top 10:2025, A10:2025 Mishandling of Exceptional Conditions (description, mapped CWEs, score table, prevention, example attack scenarios). https://top10.owasp.org/2025/A10_2025-Mishandling_of_Exceptional_Conditions/
- OWASP Top 10:2025 category list. https://top10.owasp.org/2025
- MITRE CWE entries 209, 252, 390, 396 and 636. https://cwe.mitre.org/
- Node.js documentation:
node:sqlite,node:crypto(randomUUID).
Top comments (0)