Web Security (OWASP Top 10:2025) Module 11 – A10:2025 Mishandling of Exceptional Conditions
Fail closed: error handling that does not leak
Handle A10:2025 exceptional conditions: refuse when a check cannot answer, send safe RFC 9457 errors with a correlation ID, and decide what missing input means.
What you will learn
- Explain A10:2025 and its three failings: not preventing, not detecting and not responding to exceptional conditions
- Replace a fail-open check with one that refuses and logs when it cannot decide
- Send problem details (RFC 9457) with a correlation ID for every error, and keep the detail in the server log
- Prevent leaks through stack traces, database messages and debug detail, with a test that fails on any leak
- Handle missing, extra and null input explicitly instead of letting the language decide
Before you start
What you’ll learn · Before you start
What you will learn
- Explain A10:2025 and its three failings: not preventing, not detecting and not responding to exceptional conditions
- Replace a fail-open check with one that refuses and logs when it cannot decide
- Send problem details (RFC 9457) with a correlation ID for every error, and keep the detail in the server log
- Prevent leaks through stack traces, database messages and debug detail, with a test that fails on any leak
- Handle missing, extra and null input explicitly instead of letting the language decide
Before you start
On this page
The refund route asks a policy service whether a user may refund an order. When the service stops answering, the route’s
catch block says yes, and every refund goes through for as long as the outage lasts. In this lesson’s first program,
that route approves 5 of 5 requests that nobody could decide, and a route that fails closed approves none.
OWASP gave this family of mistakes its own place in the 2025 Top 10: A10, Mishandling of Exceptional Conditions. You will name its three failings, make a check refuse when it cannot answer, turn every error into one safe answer with an ID that finds the log line, and decide what a missing, extra or null value means before the language decides for you. Each check is a test: it fails on the weak code and passes on the fixed code.
A10:2025 names three failings: prevent, detect, respond
An exceptional condition: Anything the normal path of a program did not plan for: a missing value, a failed call, a timeout, a full disk. OWASP's A10:2025 asks you to prevent, notice and answer them on purpose. is anything the normal path did not plan for: a value that is missing, a call that fails, a service that is slow, a disk that is full. OWASP’s A10:2025 groups 24 weaknesses about mishandling them. Its data counts 769,581 occurrences and 3,416 CVEs, with an average incidence of 2.95 % of the applications tested and a highest incidence of 20.67 % for one weakness. OWASP’s test for the category is simple: any time the code is unsure of its next instruction, an exceptional condition has been mishandled.
The category names three ways to fail. The application does not prevent the unusual situation, does not notice it while it happens, or responds to it badly or not at all. Each has a shape in code and a control that ASVS 5.0.0 turns into a requirement:
| Failing | What the code does | The control | ASVS 5.0.0 |
|---|---|---|---|
| Does not prevent | Trusts missing, extra or malformed input and sets no limits | Strict validation, and limits on size, count and time | 2.2.1 |
| Does not detect | Swallows the error, or logs it where nobody looks, and never counts repeats | Log unexpected errors, count repeats, alert on a rate | 16.3.4 |
| Does not respond | Says yes when it cannot decide, leaks detail, leaves work half done | Refuse, answer generically, roll back what was done | 16.5.1, 16.5.3 |
The rows follow the order of an incident. Missing input checks let the situation start, a swallowed error hides it, and a
catch that says yes or a page that prints a stack trace makes it worse. OWASP’s three example scenarios are the same
failings at work: an upload handler that catches its error and never releases the resources it held, a database error that prints its
whole message so that someone can learn the table names, and a money transfer interrupted between its debit and its
credit. This lesson takes the second one. The next lesson takes the first and the third.
Key idea
A10 asks three things of every error path: prevent the situation with checks and limits, notice it with logs and counts, and respond by refusing, answering generically and undoing what was done.
Checkpoint
Check: The three failings of A10:2025
When a check cannot answer, the answer is no
Return to the refund route. The policy service can answer in seven ways. Two are what the author planned, a yes and a no.
The other five are what production does: the service is down, it does not answer in time, a new version renamed allow to
decision, the body is empty, or a proxy in front of it returns its own error page. Three versions of the route read these
answers: the weak one, one whose catch block was fixed, and one that fails closed.
// A refund route asks a policy service whether the caller may refund an order. The service can answer in seven ways.
// Three versions of the route read the answers: the weak one, one with only its catch block fixed, and one that fails closed.
function withTimeout(promise, ms) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(error) => { clearTimeout(timer); reject(error); },
);
});
}
// What the policy service does on seven requests. The last five are the cases nobody planned for.
const SERVICE = [
{ name: 'allow: true', call: async () => ({ allow: true }) },
{ name: 'allow: false', call: async () => ({ allow: false }) },
{ name: 'service is down', call: async () => { throw new Error('connect ECONNREFUSED 10.0.0.12:8443'); } },
{ name: 'no answer in time', call: () => new Promise(() => {}) },
{ name: 'field was renamed', call: async () => ({ decision: 'permit' }) },
{ name: 'empty body', call: async () => null },
{ name: 'proxy error page', call: async () => '<html>502 Bad Gateway</html>' },
];
// Weak: allowed unless the answer says no, and allowed when anything goes wrong.
async function weakRoute(call) {
try {
const answer = await withTimeout(call(), 50);
return { refund: answer.allow !== false };
} catch (error) {
return { refund: true }; // keep the shop working
}
}
// Half fixed: the catch block now refuses, but an answer without the field is still read as "not no".
async function catchFixedRoute(call) {
try {
const answer = await withTimeout(call(), 50);
return { refund: answer.allow !== false };
} catch (error) {
return { refund: false };
}
}
// Fixed: allowed only on an explicit true. Anything else is refused, and the reason goes to the log.
async function fixedRoute(call, log) {
let answer;
try {
answer = await withTimeout(call(), 50);
} catch (error) {
log.push(`policy unavailable: ${error.message}`);
return { refund: false, status: 503 };
}
if (answer === null || typeof answer !== 'object' || typeof answer.allow !== 'boolean') {
log.push('policy answer not understood');
return { refund: false, status: 503 };
}
return answer.allow ? { refund: true, status: 200 } : { refund: false, status: 403 };
}
const log = [];
const wrong = { weak: 0, half: 0, fixed: 0 };
console.log('answer of the policy service weak catch fixed fixed route');
for (const [index, service] of SERVICE.entries()) {
const weak = await weakRoute(service.call);
const half = await catchFixedRoute(service.call);
const fixed = await fixedRoute(service.call, log);
if (index >= 2) { // the service could not give a usable yes or no
if (weak.refund) wrong.weak++;
if (half.refund) wrong.half++;
if (fixed.refund) wrong.fixed++;
}
const word = (route) => (route.refund ? 'refund' : 'refuse');
console.log(`${service.name.padEnd(29)} ${word(weak).padEnd(8)} ${word(half).padEnd(12)} ${word(fixed)} (${fixed.status})`);
}
console.log(`\nRefunds approved when the service could not decide: weak ${wrong.weak} of 5, catch fixed ${wrong.half} of 5, fixed ${wrong.fixed} of 5`);
console.log(`Log lines written by the fixed route: ${log.length}`);
if (log.length >= 3) console.log('Alert: 3 or more failures of the policy service, page the owner'); Output
answer of the policy service weak catch fixed fixed route allow: true refund refund refund (200) allow: false refuse refuse refuse (403) service is down refund refuse refuse (503) no answer in time refund refuse refuse (503) field was renamed refund refund refuse (503) empty body refund refuse refuse (503) proxy error page refund refund refuse (503) Refunds approved when the service could not decide: weak 5 of 5, catch fixed 2 of 5, fixed 0 of 5 Log lines written by the fixed route: 5 Alert: 3 or more failures of the policy service, page the owner
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node policy_check.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The weak route approved all 5 undecided requests, because two habits let them through. Its catch block returns true,
which covers the answers that throw or never arrive. Its test answer.allow !== false reads anything but an explicit no
as a yes, which covers the renamed field and the proxy page. The middle route shows why fixing one is not enough: with the
catch block refusing, 2 of 5 still went through. Only the failing closed: Refusing a request when the check that should decide it cannot run, and rolling back any work done part of the way, instead of letting the request through. More in “Security principles every builder applies” route allowed
nothing it had not been told to allow, because it accepts one answer, an object whose allow is the boolean true, and
refuses every other thing. The opposite habit has a name, to fail open: Letting an action through when the check that should decide it cannot run or cannot be read, for example because its service timed out. The opposite of failing closed, and the weakness behind CWE-636., and a weakness number,
CWE-636.
The fixed route also tells two refusals apart. A 403 says that the policy decided no. A 503 says that nothing was decided and that trying again later is reasonable, which RFC 9110 defines as the service being unable to handle the request for now. Its five log lines are what an alert rule counts: the program’s rule is three or more, so it prints an alert. Step through the same outage and see what the agent, the log and the owner each get:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A refund is requested. A support agent presses Refund on order A-101. The route does not decide by itself whether the agent may refund it; it asks the policy service first.
- The service does not answer. The route sends the question and starts a 50 ms timer. The policy service is down, so nothing comes back. The route now meets an exceptional condition, and its next move is the whole question.
- The weak route says yes. The timer fires and the weak route's catch block returns true, so the refund is paid although nobody decided it. The same happens to every refund until the service is back.
- The fixed route refuses. The fixed route writes policy unavailable to its log and answers 503. A 503 says that nothing was decided and that trying again later is fine, which is different from a 403, where the answer was no.
- Repeats raise an alert. The third failure trips the alert rule, so the owner of the policy service is paged while the outage is still small. One log line per failure is what the rule counts.
- The retry works. The service recovers. The agent presses Refund again, the route asks, receives an explicit yes and pays once.
Fixing one bug of two
After the catch block was fixed, answer.allow !== false still let 2 of 5 undecided refunds through. Write the
condition you accept, answer.allow === true, and let everything else fall through to the refusal.
The same habit hides in other shapes. These are worth a search through any code base:
| Where you find it | What an error or a missing value turns into | Safer shape |
|---|---|---|
catch { allowed = true } |
A yes for every error | Set it to false, log, and answer 503 |
return answer.allow !== false |
A yes for a missing field | return answer.allow === true |
except Exception: pass |
The error disappears and the next step runs | Catch the narrowest error, log it, refuse or roll back |
| A flag service that answers “on” when it is down | Unfinished features switch on | Give every flag an off default in code |
| A signature check whose exception counts as valid | Forged data passes | Treat any error as invalid |
catch (e) { console.log(e) } and then carry on |
The step failed and the next step runs | Stop, or undo what was done |
Failing closed does not mean taking the whole product down. ASVS 5.0.0 requirement 16.5.2 asks the application to keep operating securely when an external resource fails, for example with circuit breakers or graceful degradation. The word that matters is securely: degrade features, never checks. Say what the failing dependency decides, and the table rules out the fallbacks that do not fit:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Fallback | A check on who may act | A step that moves money | An optional feature |
|---|---|---|---|
| Treat it as yes The caller: carries on as if allowed | Poor Everyone gets in while the check is down: 5 of 5 undecided refunds went through | Poor Money moves although nobody decided that it may | Good Nothing depends on the answer, so carrying on does no harm |
| Refuse the request The caller: answers 503 and logs | Good The request is refused and logged, and the user can retry when the service is back | Good Nothing moves, and a retry with the same idempotency key pays once | Mixed Safe but heavy, because one failed box takes the whole page down |
| Reuse the last answer The caller: a copy with a maximum age | Mixed Fine while the copy is young, and a refusal once it is older than its limit | Poor A balance or a price from before the outage can be wrong now | Good A slightly old list of suggestions does no harm |
| Leave the feature out The caller: serves the rest of the page | Poor Leaving out a check is the same as allowing everything | Poor Skipping a fraud check lets every payment through | Good The page works without the optional box |
How the answers decide
- “What does the failing dependency decide?” Who may act: rules out Treat it as yes and Leave the feature out. A check that cannot run must not become a yes, and leaving it out is the same as saying yes.
- “What does the failing dependency decide?” Who may act and “Is an answer from a few minutes ago still right?” Yes, it changes rarely: adds a caveat to Reuse the last answer. Roles change rarely, so a recent answer can stand in if it has a maximum age and a refusal beyond it.
- “What does the failing dependency decide?” Who may act and “Is an answer from a few minutes ago still right?” No, it can change at once: rules out Reuse the last answer. An answer that can change at once cannot be reused, because the change is what the check is for.
- “What does the failing dependency decide?” Whether money or stock moves: rules out Treat it as yes, Leave the feature out and Reuse the last answer. Money and stock need a fresh decision, so refuse and let the client retry with the same idempotency key.
- “What does the failing dependency decide?” Something optional: adds a caveat to Refuse the request. Refusing a whole page for an optional box is safe but costly, so skip the box or reuse its last answer.
In short, a check on who may act fails closed, a step that moves money fails closed and is retried with an idempotency key, as the lesson on races shows, and an optional box may be left out or served from its last answer.
Key idea
When a check cannot decide, the answer is no: allow only on an explicit yes, and degrade features, never checks.
Checkpoint
Check: Fail closed when a check cannot answer
One handler turns every failure into the same safe answer
A route that fails closed still has to say something to its client. For an HTTP API the standard way is
problem details: The JSON error format of RFC 9457, sent as application/problem+json: a type, a title, a status, a detail for this occurrence and an instance that identifies it, so that every error has one predictable shape., RFC 9457: a JSON object sent as application/problem+json, with a type
that names the kind of problem, a title that stays the same from one occurrence to the next, the status, a detail
for this occurrence and an instance that identifies it. The RFC says that detail should help the client correct the
problem and not describe the server’s insides, and its security section warns against making a stack dump available
through the HTTP interface.
The next program sends seven requests that fail in seven ways. Four failures were expected: invalid input, a missing order, a role that may not do the action and a payment provider that timed out. Three were not: a bug, a database error and a driver error whose message holds a connection string. The weak handler answers all of them the way many frameworks do, from the error itself. The central handler is the one place that decides what the client may see:
// Seven requests to a small shop API fail in seven different ways. A weak handler sends what a framework would
// (the message, the stack, the query). A central handler sends a problem document (RFC 9457) with a correlation ID
// and keeps the detail in the server log.
// An expected failure: its status and its words were chosen on purpose, so they are safe to show.
class AppError extends Error {
constructor(status, slug, title, detail, extra = {}) {
super(detail);
Object.assign(this, { status, slug, title, extra });
}
}
function withStack(error) {
// A fixed stack, so every run prints the same text (a real one names the files of your own server).
error.stack = `${error.name}: ${error.message}\n at handler (/srv/shop/src/routes.js:88:13)\n at route (/srv/shop/node_modules/router-kit/4.1.2/index.js:208:9)`;
return error;
}
const REQUESTS = [
{ label: 'POST /orders, quantity "ten"', fail: () => new AppError(422, 'validation', 'The request was not valid', 'Fix the fields listed in errors and send it again.', { errors: [{ field: 'quantity', message: 'must be a whole number from 1 to 99' }] }) },
{ label: 'GET /orders/9999', fail: () => new AppError(404, 'not-found', 'Not Found', 'There is no order with this number.') },
{ label: 'DELETE /orders/41, role viewer', fail: () => new AppError(403, 'forbidden', 'Forbidden', 'Your role cannot delete orders.') },
{ label: 'POST /pay, provider timed out', fail: () => Object.assign(new AppError(503, 'dependency', 'Payment service unavailable', 'Try again in a minute.'), { retryAfter: 60 }) },
{ label: 'GET /orders/41/invoice (a bug)', fail: () => new TypeError("Cannot read properties of undefined (reading 'email')") },
{ label: 'GET /search (database error)', fail: () => Object.assign(new Error('SQLITE_ERROR: no such column: users.pwd_hash'), { sql: 'SELECT pwd_hash FROM users WHERE id = ?' }) },
{ label: 'POST /connect (driver error)', fail: () => new Error('connect ECONNREFUSED postgres://shop:fake-password@db.internal:5432/shop') },
];
// Weak: one answer for every failure, built from the error itself.
function weakHandler(error) {
return { status: 500, headers: {}, body: { error: error.message, stack: error.stack, sql: error.sql } };
}
// Central: expected failures keep their status and words; anything else becomes a generic 500 with the same words.
// The detail goes to the log under the request's id.
let counter = 0;
const serverLog = [];
function centralHandler(error) {
const id = `req-${String(++counter).padStart(4, '0')}`;
const expected = error instanceof AppError;
const status = expected ? error.status : 500;
const headers = { 'Content-Type': 'application/problem+json', 'X-Request-Id': id };
if (error.retryAfter) headers['Retry-After'] = String(error.retryAfter);
const body = expected
? { type: `https://api.example.com/problems/${error.slug}`, title: error.title, status, detail: error.message, instance: `/errors/${id}`, ...error.extra }
: { type: 'about:blank', title: 'Internal Server Error', status, detail: 'Something went wrong. Quote the instance to support.', instance: `/errors/${id}` };
serverLog.push({ id, status, error: `${error.name}: ${error.message}`, stack: expected ? null : `${error.stack.split('\n').length} lines kept` });
return { status, headers, body };
}
// The regression check: nothing a client receives may hold a stack frame, a query, a connection string or a path.
const LEAK = /\n\s+at |SQLITE|SELECT |postgres:\/\/|\/srv\/|node_modules|ECONNREFUSED|TypeError|Cannot read/;
const text = (body) => Object.values(body).map((value) => (typeof value === 'string' ? value : JSON.stringify(value))).join('\n');
const leaks = (response) => LEAK.test(text(response.body));
const answers = REQUESTS.map((request) => ({ label: request.label, weak: weakHandler(withStack(request.fail())), central: centralHandler(withStack(request.fail())) }));
console.log('request weak handler central handler');
for (const { label, weak, central } of answers) {
const weakText = `${weak.status}, ${leaks(weak) ? 'leaks' : 'clean'}`.padEnd(13);
console.log(`${label.padEnd(31)} ${weakText} ${central.status} ${central.body.instance}, ${leaks(central) ? 'leaks' : 'clean'}`);
}
const weakLeaks = answers.filter((a) => leaks(a.weak)).length;
const centralLeaks = answers.filter((a) => leaks(a.central)).length;
console.log(`\nResponses with internal detail: weak ${weakLeaks} of ${answers.length}, central ${centralLeaks} of ${answers.length}`);
console.log(`Retry-After on the payment timeout: ${answers[3].central.headers['Retry-After']}`);
const sixth = answers[5].central;
console.log('\nWhat the client of request 6 receives:');
console.log(JSON.stringify(sixth.body, null, 2));
console.log('\nWhat the server log keeps for the same id:');
console.log(JSON.stringify(serverLog.find((entry) => `/errors/${entry.id}` === sixth.body.instance))); Output
request weak handler central handler
POST /orders, quantity "ten" 500, leaks 422 /errors/req-0001, clean
GET /orders/9999 500, leaks 404 /errors/req-0002, clean
DELETE /orders/41, role viewer 500, leaks 403 /errors/req-0003, clean
POST /pay, provider timed out 500, leaks 503 /errors/req-0004, clean
GET /orders/41/invoice (a bug) 500, leaks 500 /errors/req-0005, clean
GET /search (database error) 500, leaks 500 /errors/req-0006, clean
POST /connect (driver error) 500, leaks 500 /errors/req-0007, clean
Responses with internal detail: weak 7 of 7, central 0 of 7
Retry-After on the payment timeout: 60
What the client of request 6 receives:
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Something went wrong. Quote the instance to support.",
"instance": "/errors/req-0006"
}
What the server log keeps for the same id:
{"id":"req-0006","status":500,"error":"Error: SQLITE_ERROR: no such column: users.pwd_hash","stack":"3 lines kept"}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node central_handler.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The weak handler sent internal detail in 7 of 7 answers: stack frames with the server’s paths and a package version, a column
name, the query and a connection string with a password. The central handler sent none. It kept the status and the words of
the four expected failures (422, 404, 403 and 503, the last with Retry-After: 60), and it gave all three unexpected ones
the same generic 500 of type about:blank, which RFC 9457 defines as a problem with no meaning beyond its status. The
client of request 6 learns nothing about the cause, and the log keeps the cause under the same ID. Follow one failure
through the layers it can meet:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A call fails. A route calls a payment provider, a database or its own code, and something throws. At this moment the code is unsure of its next instruction, which is what OWASP means by an exceptional condition.
- Handle it where it happens. The nearest code that knows what the call was for can do something meaningful, such as retry once, use a safe fallback, or add context and pass the error on. It never swallows the error and never answers yes.
- A catch that is missing. Where no code catches the error, it travels up to the one central handler. That handler is the backstop that ASVS 5.0.0 calls a last-resort handler (requirement 16.5.4), so a forgotten catch costs a generic answer and not a leak.
- One handler decides. The central handler asks one question, whether this is an error whose words were chosen to be shown. If it is, the client gets its status and message. If it is anything else, the client gets a generic 500 that says nothing about the cause.
- The detail goes to the log. The handler writes the message, the stack and the status to the server log under a correlation id. The log is read by the people who fix the bug, and nobody else.
- Repeats are counted. The same error again and again is a signal. A counter collapses the repeats into one line with a count, and an alert fires when the rate passes a limit.
- The client gets a short answer. The response is a problem document with a type, a title, a status and an instance that holds the correlation id. The client can quote the instance to support, and support finds the log line.
Two rules keep the handler honest. First, it is the only place that builds an error response, and routes throw instead of answering for themselves. OWASP asks for errors to be handled in one place, the same way each time, so that review and testing have one target. Second, the leak check at the end of the program becomes a test that runs on every route: a body that holds a stack frame, a query, a connection string or a path fails the build. That is the “check that fails on the weak version and passes on the fix”, kept for good.
HTTP Status Codes Reference Look up the statuses used in the seven answers, from 403 and 422 to 503, and what each tells a client to do.The handler covers an error thrown while a request is handled. An error in a timer or a background job needs a last-resort handler: The one handler that catches every error nothing else caught, so that it is logged and answered safely instead of leaking detail or leaving the request in an unknown state. ASVS 5.0.0 asks for it in requirement 16.5.4. at the level of the process. Node.js treats an unhandled promise rejection as an uncaught exception by default, and its documentation says that after an uncaught exception the application is in an undefined state and it is not safe to resume normal operation. Log it, release resources synchronously, exit, and let a supervisor start a fresh process.
Checkpoint
Check: One handler, safe answers, no leaks
Log the detail under an ID, and count the repeats
The client’s short answer helps only if someone can find the long one. The correlation ID: An identifier given to a request when it arrives and written on every log entry made while the request is handled, in every service it touches, so that the entries can be found together. Also called a request ID. More in “Security logging: what to record, what never to” is
the link: the handler makes an ID, puts it in the response as the instance and in a header, and writes it on the log line
together with the message, the stack and the status. A customer who quotes req-0006 hands support the exact line. What
the line may hold, and what never belongs in it, is the subject of the logging
lesson.
The log has a second job, which is to notice. One failing dependency can write the same error hundreds of times a minute. A log that writes every one buries the rare error under the common one, and a log that silently drops repeats hides their rate, which is the signal. OWASP suggests a middle way: output identical repeated errors, above a certain rate, as statistics that say how often they happened and in what time span, appended to the original message. This program applies it to 143 events:
"""One failing dependency can write the same error thousands of times. This log writes the first of each kind in full,
counts the repeats, says how many there were when the minute ends, and raises one alert when the count gets high."""
class CollapsingLog:
def __init__(self, window=60, alert_at=50):
self.window = window # seconds in which repeats of one kind are counted
self.alert_at = alert_at # occurrences in one window that raise an alert
self.lines = [] # what reaches the log file
self.open = {} # message template -> the window being counted
def error(self, t, template, *args):
self._close_windows(t)
entry = self.open.get(template)
if entry is None: # the first of its kind in this window is written in full
self.open[template] = {"start": t, "count": 1, "last": t, "alerted": False}
self.lines.append(f"{t:>3}s ERROR {template % args}")
return
entry["count"] += 1 # a repeat is counted, not written
entry["last"] = t
if entry["count"] == self.alert_at and not entry["alerted"]:
entry["alerted"] = True
self.lines.append(f"{t:>3}s ALERT {template!r}: {self.alert_at} in {t - entry['start'] + 1} s")
def _close_windows(self, now, force=False):
for template, entry in list(self.open.items()):
if force or now - entry["start"] >= self.window:
if entry["count"] > 1:
seconds = entry["last"] - entry["start"] + 1
self.lines.append(f"{now:>3}s ERROR {template!r} repeated {entry['count'] - 1} more times in {seconds} s")
del self.open[template]
def finish(self, now):
self._close_windows(now, force=True)
raw = [] # a log that writes every event
log = CollapsingLog()
def error(t, template, *args):
raw.append(f"{t:>3}s ERROR {template % args}")
log.error(t, template, *args)
for second in range(90):
if second < 70: # the payment provider is down for 70 seconds, two requests a second
for order in (4100 + 2 * second, 4101 + 2 * second):
error(second, "payment provider timed out for order %d", order)
if second == 12:
error(second, "invoice template has no field %r", "tax_id")
if second == 31:
error(second, "disk is %d%% full", 91)
if second == 55:
error(second, "webhook event %r is not known", "refund.reversed")
log.finish(90)
print("\n".join(log.lines))
print()
print(f"Events: {len(raw)}. Lines in a log that writes every event: {len(raw)}. Lines in the collapsing log: {len(log.lines)}.")
print(f"Alerts raised: {sum(1 for line in log.lines if ' ALERT ' in line)}.")
rare = [line for line in log.lines if "tax_id" in line or "disk is" in line or "refund.reversed" in line]
print(f"The three rare errors are all in the log in full: {len(rare) == 3}.") Output
0s ERROR payment provider timed out for order 4100 12s ERROR invoice template has no field 'tax_id' 24s ALERT 'payment provider timed out for order %d': 50 in 25 s 31s ERROR disk is 91% full 55s ERROR webhook event 'refund.reversed' is not known 60s ERROR 'payment provider timed out for order %d' repeated 119 more times in 60 s 60s ERROR payment provider timed out for order 4220 90s ERROR 'payment provider timed out for order %d' repeated 19 more times in 10 s Events: 143. Lines in a log that writes every event: 143. Lines in the collapsing log: 8. Alerts raised: 1. The three rare errors are all in the log in full: True.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 repeat_errors.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The payment provider’s error is written in full once, then counted: 119 repeats in the first minute, 19 in the last ten seconds. One alert fires at the 50th occurrence, at second 24, which is the moment an owner can still act. The three rare errors, a template with a missing field, a disk at 91 % and an unknown webhook event, are all in the log in full, because nothing common hid them. A sudden new kind of error is as interesting as a rising one. OWASP adds that monitoring which watches for repeated errors can catch scripts and bots that probe an application’s error handling, and the alerting lesson turns such counts into rules with owners.
Checkpoint
Check: Correlation IDs, logs and repeated errors
Missing, extra and null: decide each case before the language does
Code that does not decide what a missing value means lets the language decide, and the language rarely decides in favour of security. OWASP maps several weaknesses of this kind to A10. CWE-234 is a missing parameter. CWE-235 is an extra one. CWE-252 is a return value that nobody checked. CWE-476 is a null that is used as if it held something.
A credit endpoint takes a small JSON body. In the weak route, parseFloat turns a missing or text amount into NaN, and
NaN > 5000 is false in JavaScript (MDN documents that a relational comparison with NaN is always false), so the limit never fires. A
failed lookup returns undefined, the next line reads a property of it and the request ends in a stack trace. The record
is built by copying every field of the body, including one that the form never sends. The fixed route names, types and
bounds every field, refuses unknown ones, and checks what the lookup returned:
// A credit endpoint takes a small JSON body from the app. Nine bodies, a weak route and a fixed route.
// The weak route never decides what a missing, extra or null value means, so JavaScript decides for it.
const LIMIT = 5000;
const ORDERS = new Map([['A-101', { customer: 'asha' }], ['A-102', { customer: 'ravi' }]]);
const REASONS = new Set(['damaged', 'late', 'wrong item']);
const BODIES = [
['a normal request', { orderId: 'A-101', amount: 250, reason: 'damaged' }],
['amount missing', { orderId: 'A-101', reason: 'damaged' }],
['amount is text', { orderId: 'A-101', amount: 'lots', reason: 'damaged' }],
['amount is negative', { orderId: 'A-101', amount: -250, reason: 'damaged' }],
['amount over the limit', { orderId: 'A-101', amount: 90000, reason: 'damaged' }],
['extra field approvedBy', { orderId: 'A-101', amount: 250, reason: 'damaged', approvedBy: 'ceo' }],
['unknown order', { orderId: 'Z-999', amount: 250, reason: 'damaged' }],
['order id is null', { orderId: null, amount: 250, reason: 'damaged' }],
['no body at all', null],
];
// Weak: parseFloat gives NaN for a missing or text amount, and NaN > 5000 is false, so the limit never fires.
// The order lookup can return undefined, and the record is built by copying every field of the body.
function weakCredit(body) {
const amount = parseFloat(body.amount);
if (amount > LIMIT) return { status: 403 };
const order = ORDERS.get(body.orderId);
return { status: 200, credit: { ...body, customer: order.customer, amount } };
}
// Fixed: every field is named, typed and bounded; unknown fields are refused; a failed lookup is a 404.
// The problems name the route's own fields and rules, and never repeat what the client sent.
const FIELDS = { orderId: 'string', amount: 'number', reason: 'string' };
function fixedCredit(body) {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return { status: 400, problems: ['body: must be a JSON object'] };
const problems = [];
if (Object.keys(body).some((key) => !Object.hasOwn(FIELDS, key))) problems.push(`body: only ${Object.keys(FIELDS).join(', ')} are accepted`);
for (const [key, type] of Object.entries(FIELDS)) {
if (!Object.hasOwn(body, key)) problems.push(`${key}: required`);
else if (typeof body[key] !== type) problems.push(`${key}: must be a ${type}`);
}
if (!problems.length) {
if (!Number.isFinite(body.amount) || body.amount <= 0 || body.amount > LIMIT) problems.push(`amount: above 0 and at most ${LIMIT}`);
if (!REASONS.has(body.reason)) problems.push('reason: not a listed reason');
}
if (problems.length) return { status: 422, problems };
const order = ORDERS.get(body.orderId);
if (order === undefined) return { status: 404, problems: ['orderId: no such order'] };
return { status: 200, credit: { orderId: body.orderId, customer: order.customer, amount: body.amount, reason: body.reason } };
}
function describe(result) {
if (result.credit) {
const extra = Object.keys(result.credit).filter((key) => !['orderId', 'customer', 'amount', 'reason'].includes(key));
return `${result.status} credit ${result.credit.amount}${extra.length ? ` +${extra.join(',')}` : ''}`;
}
return `${result.status}${result.problems ? ` ${result.problems.join('; ')}` : ''}`;
}
let weakWrong = 0;
let weakCrashes = 0;
let fixedWrong = 0;
console.log('body weak route fixed route');
for (const [name, body] of BODIES) {
let weak;
try {
weak = weakCredit(body);
} catch (error) {
weak = { status: 500, crashed: error.name };
}
const fixed = fixedCredit(body);
if (name !== 'a normal request') {
if (weak.crashed) weakCrashes++;
else if (weak.status === 200) weakWrong++;
if (fixed.status === 200) fixedWrong++;
}
console.log(`${name.padEnd(23)} ${(weak.crashed ? `500 ${weak.crashed}` : describe(weak)).padEnd(27)} ${describe(fixed)}`);
}
console.log(`\nOf the 8 bad bodies, the weak route approved ${weakWrong} and crashed on ${weakCrashes}; the fixed route approved ${fixedWrong}.`); Output
body weak route fixed route a normal request 200 credit 250 200 credit 250 amount missing 200 credit NaN 422 amount: required amount is text 200 credit NaN 422 amount: must be a number amount is negative 200 credit -250 422 amount: above 0 and at most 5000 amount over the limit 403 422 amount: above 0 and at most 5000 extra field approvedBy 200 credit 250 +approvedBy 422 body: only orderId, amount, reason are accepted unknown order 500 TypeError 404 orderId: no such order order id is null 500 TypeError 422 orderId: must be a string no body at all 500 TypeError 400 body: must be a JSON object Of the 8 bad bodies, the weak route approved 4 and crashed on 3; the fixed route approved 0.
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node credit_inputs.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Of the 8 bad bodies, the weak route approved 4, among them a credit of NaN, a credit of −250 and a record carrying
approvedBy: ceo, and it crashed on 3. The fixed route approved none. Its refusals use the codes that fit: 400 when the body
is not a JSON object at all, 422 when it is well formed but breaks a rule, and 404 when the order does not exist. Its
messages name the route’s own fields and the rule, and never repeat what the client sent, so an error cannot echo hostile text back.
Test what you reject, not what you accept
if (amount > limit) refuse() lets NaN through, because the comparison is false. Write the condition you accept and
refuse the rest: if (!(amount > 0 && amount <= limit)) refuse() refuses NaN, a missing value and a negative number in
one line.
Rejecting an extra field is the strict default. Ignoring it is a choice some APIs make for older clients, and the only rule that matters then is that an ignored field is never copied onto a record, which the lesson on mass assignment explains.
Key idea
Name every field, check its type and range before you use it, refuse the fields you did not name, and check what every lookup and call returned. A missing or null value is a case you wrote, not one the language chose.
Checkpoint
Check: Missing, extra and null input
Practise: a closed check and a central handler
Two small functions make the core of this lesson. The first decides a refund from the policy service’s answer and fails closed, with a timeout and a log entry. The second turns any thrown value into a problem document, with a correlation ID, and writes the detail to the log. Each step starts from code that fails its sample tests. Run them in the page, and open the hint when you are stuck:
Code lab · 2 steps
Build a closed check and a central handler
Refuse every refund the policy service did not allow, then answer every error with one safe problem document and keep the detail in the log.
Exercise · Easy · JavaScript
Make the refund check fail closed
The refund route asks a policy service whether a user may refund an order. canRefund(policy, user, orderId, options) wraps that call and decides what the route does next. policy(user, action, orderId) is an async function, and a usable answer is an object whose allow is the boolean true or false.
| The policy service | canRefund returns | | --- | --- | | answers { allow: true } | { allowed: true, status: 200 } | | answers { allow: false } | { allowed: false, status: 403 } | | does anything else | { allowed: false, status: 503 } |
Anything else means that the call throws or rejects, that it does not answer within options.timeoutMs (50 by default), or that it answers something that is not an object with a boolean allow: null, a text, an object without allow, { allow: "yes" }.
Every failure is also logged. Call options.log(entry) once with { event: 'policy_failure', cause }, where cause is 'error' when the call throws or rejects, 'timeout' when it is too slow and 'malformed' for an answer that is not usable. A usable answer logs nothing. canRefund never throws, asks the policy once, and ignores an answer that arrives after the timeout.
Starter code · refund.mjs
/** Decides a refund from the policy service's answer: { allowed, status }. */
export async function canRefund(policy, user, orderId, { timeoutMs = 50, log = () => {} } = {}) {
// Starter: keeps the shop working when the policy service fails.
try {
const answer = await policy(user, 'refund', orderId);
return { allowed: answer.allow !== false, status: 200 };
} catch (error) {
return { allowed: true, status: 200 };
}
} The sample tests · refund.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { canRefund } from './refund.mjs';
const later = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const fresh = () => {
const entries = [];
return { entries, log: (entry) => entries.push(entry) };
};
test('an explicit yes is allowed and logs nothing', async () => {
const { entries, log } = fresh();
const result = await canRefund(async () => ({ allow: true }), 'asha', 'A-101', { log });
assert.deepEqual(result, { allowed: true, status: 200 });
assert.deepEqual(entries, []);
});
test('an explicit no is refused with 403 and logs nothing', async () => {
const { entries, log } = fresh();
const result = await canRefund(async () => ({ allow: false }), 'asha', 'A-101', { log });
assert.deepEqual(result, { allowed: false, status: 403 });
assert.deepEqual(entries, []);
});
test('a policy that rejects is refused with 503 and logged as an error', async () => {
const { entries, log } = fresh();
const result = await canRefund(async () => { throw new Error('connect ECONNREFUSED'); }, 'asha', 'A-101', { log });
assert.deepEqual(result, { allowed: false, status: 503 });
assert.deepEqual(entries, [{ event: 'policy_failure', cause: 'error' }]);
});
test('a policy that throws before it returns a promise is handled too', async () => {
const { entries, log } = fresh();
const result = await canRefund(() => { throw new TypeError('not ready'); }, 'asha', 'A-101', { log });
assert.deepEqual(result, { allowed: false, status: 503 });
assert.deepEqual(entries, [{ event: 'policy_failure', cause: 'error' }]);
});
test('a policy that never answers is refused with 503 and logged as a timeout', async () => {
const { entries, log } = fresh();
const result = await canRefund(() => new Promise(() => {}), 'asha', 'A-101', { timeoutMs: 20, log });
assert.deepEqual(result, { allowed: false, status: 503 });
assert.deepEqual(entries, [{ event: 'policy_failure', cause: 'timeout' }]);
});
test('a yes that arrives after the timeout is ignored', async () => {
const { entries, log } = fresh();
const result = await canRefund(() => later(100, { allow: true }), 'asha', 'A-101', { timeoutMs: 20, log });
assert.deepEqual(result, { allowed: false, status: 503 });
assert.equal(entries.length, 1);
});
test('answers that are not an object with a boolean allow are refused as malformed', async () => {
for (const answer of [null, undefined, '<html>502</html>', {}, { decision: 'permit' }, { allow: 'yes' }, { allow: 1 }, [true]]) {
const { entries, log } = fresh();
const result = await canRefund(async () => answer, 'asha', 'A-101', { log });
assert.deepEqual(result, { allowed: false, status: 503 }, `answer ${JSON.stringify(answer)}`);
assert.deepEqual(entries, [{ event: 'policy_failure', cause: 'malformed' }], `answer ${JSON.stringify(answer)}`);
}
});
test('the policy is asked once, with the user, the action and the order', async () => {
const calls = [];
await canRefund(async (...args) => { calls.push(args); return { allow: true }; }, 'ravi', 'A-102');
assert.deepEqual(calls, [['ravi', 'refund', 'A-102']]);
}); A hint
Start from the answer you trust and refuse everything else: allow only when answer.allow === true. Put the call to the policy in a try, race it against a timer so that a service that never answers still ends, and decide the cause of each failure where you catch it.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · JavaScript
Send one safe answer for every error
toProblem(error, ctx) is the one place where every thrown value becomes the response a route sends. ctx.nextId() returns the next correlation id as text ('req-0001', 'req-0002' …) and ctx.log(entry) writes to the server log. Call nextId() once for each call of toProblem. The file already has AppError, the class for failures whose words were chosen to be shown: new AppError(status, slug, title, detail, { extra, retryAfter }).
Return { status, headers, body }.
| The thrown value is | status | body | | --- | --- | --- | | an AppError | its status | { type: 'https://api.example.com/problems/' + slug, title, status, detail, instance: '/errors/' + id } plus the members of extra | | anything else (any other error, a text, null) | 500 | { type: 'about:blank', title: 'Internal Server Error', status: 500, detail: 'Something went wrong. Quote the instance to support.', instance: '/errors/' + id } |
Both always send the headers Content-Type: application/problem+json and X-Request-Id: <id>, and an AppError with retryAfter adds Retry-After: <seconds as text>. The body of an unexpected error holds nothing of the original: not its message, not its stack, not a sql or other property.
Call ctx.log once with { id, status, expected, message }, where message is the error's message (or String(value) for a value that is not an error). Add stack when the thrown value has a text stack and was not an AppError. Do not change the error you are given.
Starter code · handler.mjs
/** A failure whose words were chosen to be shown to the client. */
export class AppError extends Error {
constructor(status, slug, title, detail, { extra = {}, retryAfter = null } = {}) {
super(detail);
this.name = 'AppError';
this.status = status;
this.slug = slug;
this.title = title;
this.extra = extra;
this.retryAfter = retryAfter;
}
}
/** Turns any thrown value into { status, headers, body }, and writes the detail to the server log. */
export function toProblem(error, ctx) {
// Starter: sends the error itself, as many frameworks do.
return {
status: 500,
headers: {},
body: { error: error && error.message, stack: error && error.stack, sql: error && error.sql },
};
} The sample tests · handler.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { AppError, toProblem } from './handler.mjs';
function context() {
let n = 0;
const entries = [];
return { entries, ids: () => n, nextId: () => `req-${String(++n).padStart(4, '0')}`, log: (entry) => entries.push(entry) };
}
const SAFE = 'Something went wrong. Quote the instance to support.';
test('an AppError keeps its status and words, and gets the instance and the two headers', () => {
const ctx = context();
const answer = toProblem(new AppError(404, 'not-found', 'Not Found', 'There is no order with this number.'), ctx);
assert.equal(answer.status, 404);
assert.deepEqual(answer.body, {
type: 'https://api.example.com/problems/not-found',
title: 'Not Found',
status: 404,
detail: 'There is no order with this number.',
instance: '/errors/req-0001',
});
assert.deepEqual(answer.headers, { 'Content-Type': 'application/problem+json', 'X-Request-Id': 'req-0001' });
});
test('the members of extra are added to the body, and retryAfter becomes a header', () => {
const ctx = context();
const error = new AppError(503, 'dependency', 'Payment service unavailable', 'Try again in a minute.', { extra: { errors: [{ field: 'card' }] }, retryAfter: 60 });
const answer = toProblem(error, ctx);
assert.deepEqual(answer.body.errors, [{ field: 'card' }]);
assert.equal(answer.headers['Retry-After'], '60');
assert.equal(toProblem(new AppError(404, 'not-found', 'Not Found', 'None.'), ctx).headers['Retry-After'], undefined);
});
test('an unexpected error becomes a generic 500 that shares nothing with the error', () => {
const ctx = context();
const error = Object.assign(new TypeError("Cannot read properties of undefined (reading 'email')"), { sql: 'SELECT pwd_hash FROM users WHERE id = ?' });
const answer = toProblem(error, ctx);
assert.equal(answer.status, 500);
assert.deepEqual(answer.body, { type: 'about:blank', title: 'Internal Server Error', status: 500, detail: SAFE, instance: '/errors/req-0001' });
const sent = JSON.stringify(answer);
for (const secret of ['email', 'pwd_hash', 'SELECT', 'TypeError', ' at ']) assert.equal(sent.includes(secret), false, `the answer holds "${secret}"`);
assert.deepEqual(answer.headers, { 'Content-Type': 'application/problem+json', 'X-Request-Id': 'req-0001' });
});
test('values that are not errors are answered the same way', () => {
for (const value of ['boom', null, undefined, 42, { message: 'x' }]) {
const answer = toProblem(value, context());
assert.equal(answer.status, 500, String(value));
assert.equal(answer.body.detail, SAFE);
}
});
test('the detail goes to the log under the same id, once per call', () => {
const ctx = context();
const bug = new Error('connect ECONNREFUSED db.internal:5432');
const first = toProblem(bug, ctx);
toProblem(new AppError(403, 'forbidden', 'Forbidden', 'Not yours.'), ctx);
assert.equal(ctx.entries.length, 2);
assert.equal(ctx.entries[0].id, 'req-0001');
assert.equal(first.body.instance, `/errors/${ctx.entries[0].id}`);
assert.deepEqual({ ...ctx.entries[0], stack: undefined }, { id: 'req-0001', status: 500, expected: false, message: 'connect ECONNREFUSED db.internal:5432', stack: undefined });
assert.equal(typeof ctx.entries[0].stack, 'string');
assert.deepEqual(ctx.entries[1], { id: 'req-0002', status: 403, expected: true, message: 'Not yours.' });
assert.equal(ctx.ids(), 2);
});
test('a thrown text is logged as text, without a stack', () => {
const ctx = context();
toProblem('boom', ctx);
assert.deepEqual(ctx.entries, [{ id: 'req-0001', status: 500, expected: false, message: 'boom' }]);
});
test('the error is not changed', () => {
const error = new AppError(422, 'validation', 'Invalid', 'Fix the fields.', { extra: { errors: [] } });
const before = JSON.stringify({ ...error, message: error.message });
toProblem(error, context());
assert.equal(JSON.stringify({ ...error, message: error.message }), before);
}); A hint
Decide first whether the value is an AppError with instanceof, and build the two bodies from literals, not from the error: the safe answer to an unexpected error shares nothing with it. Take the id once at the top, then use it in the header, the instance and the log entry.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test what you know about the three failings, fail-open habits, problem details and null input. The questions begin where your answers have been weakest:
Practice that adapts to you
Practise this lesson
19 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid-level): Why should an API never return an exception’s message or stack to its client, and what should it return instead?
A message and a stack describe the server: file paths, package versions, table and column names, sometimes a connection string. Someone who triggers errors on purpose reads that as reconnaissance, and every detail makes the next attempt better aimed. The client needs something else: a status that says what kind of failure it was, a short message it can act on, and an identifier it can quote. So return a problem document (RFC 9457) with a generic message for unexpected errors and a correlation ID in it, and keep the full detail in the server log under the same ID. Build that answer in one central handler instead of in each route, and add a test that fails when any response holds a stack frame, a query, a connection string or a path.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
A scan reports that /search returns the database's full error text for a malformed filter, and a teammate proposes a try/catch around that route. What do you change instead, how do you prove it for every route, and what about the routes nobody has tested?
-
Mid to staff
A feature-flag service was down for 20 minutes and every flag read as on, including an unfinished flag that skips an identity check for returning customers. Review how the flags are read, and describe the change and the tests that would have made this outage harmless.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which three failings does OWASP's A10:2025 name, and what controls answer them?
The application does not prevent the unusual situation, does not notice it while it happens, or responds badly or not at all. Controls: strict validation and limits on size, count and time (ASVS 5.0.0 2.2.1); logging unexpected errors, counting repeats and alerting on a rate (16.3.4); refusing, answering generically and rolling back (16.5.1, 16.5.3). OWASP's test: any time the code is unsure of its next instruction, an exceptional condition has been mishandled.
-
What does it mean to fail closed, and what is the opposite?
When a check cannot decide, the answer is no: allow only on an explicit yes. The opposite is to fail open (CWE-636). In the lesson the weak refund route allowed 5 of 5 undecided requests, fixing only the catch block still let 2 of 5 through, and the route that accepts only an object whose allow is the boolean true allowed none.
-
Why write the condition you accept and not the one you reject?
answer.allow !== false reads a missing field or a proxy's error page as a yes, while answer.allow === true lets everything else fall through to the refusal. The same goes for limits: NaN > 5000 is false, so the limit never fires; write !(amount > 0 && amount <= limit), which refuses NaN, a missing value and a negative number in one line.
-
What does ASVS 5.0.0 16.5.2 ask for, and what may degrade when a dependency fails?
That the application keeps operating securely when an external resource fails, for example with circuit breakers or graceful degradation. Degrade features, never checks: a check on who may act fails closed, a step that moves money fails closed and is retried with an idempotency key, and an optional box may be left out or served from its last answer.
-
What should an API send for an unexpected error, and what stays in the log?
A problem document (RFC 9457, application/problem+json) with a generic title, the status and an instance ID, such as a generic 500 of type about:blank. The stack, query and connection string stay in the server log under the same correlation ID. One central handler builds every error response, routes throw instead of answering, and a leak check fails the build on any body holding a stack frame, query, connection string or path.
-
How should a log treat many identical errors?
Neither flood nor drop them: write the first in full, then output repeats above a rate as statistics saying how often they happened and in what time span. In the lesson 143 events became 8 lines, one alert fired at the 50th occurrence, and the rare errors (a template with a missing field, a disk at 91 %, an unknown webhook event) stayed visible in full.
-
What does a last-resort handler do after an uncaught exception in Node.js?
Node treats an unhandled promise rejection as an uncaught exception by default, and after an uncaught exception the application is in an undefined state. Log it, release resources synchronously, exit, and let a supervisor start a fresh process.
-
How should code treat missing, extra and null values?
Decide each case before the language does: name and bound every field, refuse fields you did not name, and check what every lookup returned. CWE-234 is a missing parameter, CWE-235 an extra one, CWE-252 an unchecked return value and CWE-476 a null used as if it held something. Answer 400 when the body is not a JSON object, 422 when it breaks a rule, 404 when the order does not exist, and never repeat what the client sent.
Key takeaways
- A10:2025 names three failings, not preventing, not noticing and not responding to unusual situations, and each has a control: validation and limits, logs and counts, and refusing, answering generically and undoing work.
- A check that cannot decide answers no: the weak refund route approved 5 of 5 undecided requests, a route with only its
catchblock fixed approved 2, and the one that allows only on an explicittrueapproved none. - Degrade features, never checks. A failing dependency that decides who may act or whether money moves means a 503, and an optional box may be skipped.
- One central handler gives every unexpected error the same generic 500 and keeps the detail in the log under a correlation ID; the leak check turned 7 of 7 leaking answers into 0 of 7, and it stays as a test.
- Repeated errors are counted instead of dropped or flooded: 143 events became 8 lines, one alert, and every rare error in full.
- Missing, extra and null input are cases you write: name and bound every field, refuse the rest, and write the condition
you accept, because
NaNmakes every comparison false.
Terms in this lesson
- exceptional condition
- Anything the normal path of a program did not plan for: a missing value, a failed call, a timeout, a full disk. OWASP's A10:2025 asks you to prevent, notice and answer them on purpose.
- fail open
- Letting an action through when the check that should decide it cannot run or cannot be read, for example because its service timed out. The opposite of failing closed, and the weakness behind CWE-636.
- last-resort handler
- The one handler that catches every error nothing else caught, so that it is logged and answered safely instead of leaking detail or leaving the request in an unknown state. ASVS 5.0.0 asks for it in requirement 16.5.4.
- problem details
- The JSON error format of RFC 9457, sent as application/problem+json: a type, a title, a status, a detail for this occurrence and an instance that identifies it, so that every error has one predictable shape.
References
- A10:2025 Mishandling of Exceptional Conditions (OWASP Foundation)
- Error Handling Cheat Sheet (OWASP Foundation)
- RFC 9457: Problem Details for HTTP APIs (IETF (RFC Editor))
- ASVS 5.0.0, V16 Security Logging and Error Handling (16.3.4 and 16.5) (OWASP Foundation)
- ASVS 5.0.0, V2 Validation and Business Logic (2.2.1) (OWASP Foundation)
- RFC 9110: HTTP Semantics (403, 422 and 503, Retry-After) (IETF (RFC Editor))
- Node.js documentation: process, the uncaughtException event (OpenJS Foundation)
- Node.js documentation: command-line options, --unhandled-rejections (OpenJS Foundation)
- CWE-636: Not Failing Securely (Failing Open) (The MITRE Corporation (CWE))
- NaN (JavaScript reference) (MDN Web Docs (Mozilla))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress