Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 35 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 35 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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

  1. Question 1 of 2 Which control belongs to the failing "does not detect"?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Log unexpected errors, count repeats and alert on a rate

    The three failings are three jobs. Validation and limits prevent, logs and counts detect, and refusing, answering generically and rolling back respond.

  2. Question 2 of 2 A10:2025 says an application can fail in three ways. Choose every statement that describes one of them.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • It does not notice the situation while it happens
    • It responds poorly, or not at all, once the situation has happened
    • It lets an unusual situation start, for example by accepting a missing field

    The three failings are not preventing the situation, not noticing it and responding badly. Everything else on the list is a style or a policy that the category does not judge.

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.

Seven answers from a policy service, read by three refund routes JavaScript · closed/policy_check.mjs
// 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

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:

Interactive · message by message One refund while the policy service is down Step through one refund request while the policy service does not answer, and see what the weak route, the fixed route, the log and the agent each get.
One refund while the policy service is down, step 6 of 6. Messages so far: Support agent to Refund route: POST /refunds A-101; Refund route to Policy service: may asha refund A-101?; Refund route to Support agent: 503 try again later; Policy service to Refund route: allow true.Support agentpresses RefundRefund routeasks againPolicy servicebackPOST /refunds A-101may asha refund A-101?503 try again laterallow true

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Interactive · answer and compare What should a failing dependency switch off? Say what the failing dependency decides, and see which fallbacks fit and which are ruled out.
What does the failing dependency decide?
Is an answer from a few minutes ago still right?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four fallbacks when a dependency cannot answer, against three kinds of dependency
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

  1. Question 1 of 2 A team changes the catch block of the refund route to return false but keeps return answer.allow !== false. After an upgrade the policy service answers { decision: 'permit' }. What does the route do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It refunds, because the missing allow field is read as "not no"

    The route had two habits that fail open, and the team fixed one. The catch block covers answers that throw; the comparison with false covers answers that do not.

  2. Question 2 of 2 The product page loads a recommendation box from a service that is down. What should the page do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Leave the box out and serve the rest of the page

    Degrade features, never checks. A recommendation box can be left out; a check on who may act cannot.

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 failing requests through a weak handler and a central handler JavaScript · central/central_handler.mjs
// 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

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:

Interactive · stage by stage One failure on its way to a safe answer Step through one failing request and see where it can be handled, what the central handler decides, and what the log and the client each receive.
One failure on its way to a safe answer, step 7 of 7. Showing Route code, Nearest catch, Central handler (expected or not), Server log, Repeat counter (50 in one minute), Client. In focus: Client.no catch nearbyRoute codethe call fails hereNearest catchhandle it where it happensCentral handlerexpected or notServer logdetail under an idRepeat counter50 in one minuteClienta problem document only

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 In a problem document, what is the instance member for in this lesson's handler?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A unique identifier for this occurrence, the same ID that the log line carries

    The instance is how a customer's complaint finds the exact log line: the customer quotes it and support searches the log for it.

  2. Question 2 of 2 A payment provider times out while the shop handles a request. Which answer fits, as in the program?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 503 with Retry-After and words that were chosen in advance

    An expected failure keeps its own status and a safe message. A 503 with Retry-After tells the client that nothing was decided and that waiting is reasonable.

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:

143 errors in 90 seconds, written as 8 lines Python · repeats/repeat_errors.py
"""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

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

  1. Question 1 of 2 A dependency fails 120 times in a minute with the same error. Which log policy fits OWASP's advice?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Write the first one in full, then a line that says how many followed and in what span

    Collapse repeats, never delete the count. The summary line is what an alert rule and a person can act on.

  2. Question 2 of 2 In the program, why do the three rare errors appear in the log in full?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Repeats are collapsed per message template, so a rare error is the first of its own kind

    The fingerprint is the template, not the filled-in message, and each fingerprint is counted on its own.

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:

Nine bodies through a weak credit route and a fixed one JavaScript · inputs/credit_inputs.mjs
// 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

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

  1. Question 1 of 2 parseFloat("lots") is NaN. What does if (amount > 5000) refuse() do with it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing, because NaN > 5000 is false and the request goes on

    A condition that tests what to refuse lets NaN through. Write the condition that you accept, and refuse everything else.

  2. Question 2 of 2 A body carries a field the form never sends, approvedBy. Which handling is safe?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Refuse it, or ignore it by name, and never copy it onto the record

    Extra parameters (CWE-235) need an explicit decision. Refusing is the strict default, and copying is never one of the choices.

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.

  1. Make the refund check fail closed To do
  2. Send one safe answer for every error To do

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.

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.

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.

  1. Question 1 of 19 A database error prints its full message in the response, and someone keeps causing errors to learn the table names. Which of OWASP's three scenarios is this?

    Choose one answer.

    Show the answer: Question 1 of 19

    Answer: Scenario 2, sensitive information in error messages, which is reconnaissance

    Scenario 2 is the lesson's subject: an error message is information, and an application decides who receives it.

  2. Question 2 of 19 The refund route's catch block writes the error to the log at debug level and returns true. Which failings does it show?

    Choose one answer.

    Show the answer: Question 2 of 19

    Answer: Two. It does not respond, because it says yes, and it does not detect, because debug lines trigger nothing

    A catch block can hide two failings at once: a response that is wrong, and a record that nobody will read.

  3. Question 3 of 19 How many weaknesses (CWEs) does OWASP map to A10:2025?

    Type a number.

    Show the answer: Question 3 of 19

    Answer: 24 CWEs

    A10:2025 maps 24 CWEs, among them CWE-209 (an error message with sensitive information), CWE-234 (a missing parameter), CWE-476 (a null dereference) and CWE-636 (not failing securely).

  4. Question 4 of 19 The policy service answered { allow: false }. Which status does the fixed route send?

    Choose one answer.

    Show the answer: Question 4 of 19

    Answer: 403

    The fixed route separates two refusals: 403 for a decision of no, and 503 for no decision.

  5. Question 5 of 19 Which line allows a refund only on an explicit yes from the service?

    Read the code, then choose one answer.

    // A
    return answer.allow !== false;
    // B
    return answer.allow === true;
    // C
    return Boolean(answer.allow);
    Show the answer: Question 5 of 19

    Answer: B

    Write the condition you accept. A compares with the one value that means no and allows everything else, and C allows every truthy value.

  6. Question 6 of 19 In the program, how many of the 5 undecided requests did the route with only its catch block fixed approve?

    Type a number.

    Show the answer: Question 6 of 19

    Answer: 2 requests

    The renamed field and the proxy error page both leave allow undefined, and undefined !== false is true, so the half-fixed route refunded both. The other three threw or timed out and were refused.

  7. Question 7 of 19 A shop's fraud-scoring service times out at checkout, and the code treats a timeout as a score of 0, which means low risk. What should the code do instead?

    Choose one answer.

    Show the answer: Question 7 of 19

    Answer: Refuse the payment with a 503 and log it, so that the client can retry later with the same idempotency key

    The same rule as for the policy check: when the answer cannot be read, the value is the safe one. For a step that moves money, that is a refusal that can be retried.

  8. Question 8 of 19 Which type does the central handler give an unexpected error, and what does it mean?

    Choose one answer.

    Show the answer: Question 8 of 19

    Answer: about:blank, a problem with no meaning beyond its HTTP status

    An unexpected error has no problem type worth a client's attention, so it uses the default that says "see the status".

  9. Question 9 of 19 Where should the response to an error be built?

    Choose one answer.

    Show the answer: Question 9 of 19

    Answer: In one central handler that every route's errors reach

    Routes throw and one handler answers. That also makes the leak check a single test.

  10. Question 10 of 19 Which of these should make the leak check fail on an error response? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 10 of 19

    Answer:

    • A stack frame such as "at handler (/srv/shop/src/routes.js:88:13)"
    • A query such as "SELECT pwd_hash FROM users WHERE id = ?"
    • A connection string with a user name and a password

    The check looks for what describes the server and lets through what the client is meant to quote.

  11. Question 11 of 19 A timer callback throws and nothing catches it. What does Node's documentation say is safe to do next?

    Choose one answer.

    Show the answer: Question 11 of 19

    Answer: Release resources synchronously, exit, and let a supervisor restart the process

    The central handler covers requests. For everything else the last resort is to clean up, exit and restart in a known state.

  12. Question 12 of 19 What belongs on the log line under the correlation ID? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 12 of 19

    Answer:

    • The error message
    • The stack
    • The status of the response

    The line holds what helps a fixer and nothing that makes the log itself a target.

  13. Question 13 of 19 The payment error arrives twice a second from second 0, and the alert fires at the 50th occurrence. At which second does it fire?

    Type a number.

    Show the answer: Question 13 of 19

    Answer: 24 second

    Two occurrences a second reach 50 after 25 seconds of data, and the 50th is the second occurrence of second 24.

  14. Question 14 of 19 How many lines did the collapsing log write for the 143 events of the program?

    Type a number.

    Show the answer: Question 14 of 19

    Answer: 8 lines

    Five are first occurrences (four kinds of error, and the payment error again in the second window), one is the alert and two are summaries of the payment error. A log that wrote every event would have 143.

  15. Question 15 of 19 An alert rule counts repeats of the same error. What does a brand-new kind of error at 3 a.m. need?

    Choose one answer.

    Show the answer: Question 15 of 19

    Answer: Its own first line in full and a way to be noticed, because rare errors are often the interesting ones

    Counting repeats finds a rising problem. A separate rule for first-seen errors finds a new one.

  16. Question 16 of 19 How many of the 8 bad bodies crashed the weak credit route?

    Type a number.

    Show the answer: Question 16 of 19

    Answer: 3 bodies

    The unknown order, the null order id and the missing body each ended in a TypeError because the code read a property of undefined or null.

  17. Question 17 of 19 A request body is the text null. Which status does the fixed route send?

    Choose one answer.

    Show the answer: Question 17 of 19

    Answer: 400, because the body is not a JSON object at all

    Each status says something different to the client: the body is unusable, a rule is broken, or the thing asked for does not exist.

  18. Question 18 of 19 Which line refuses NaN, a missing amount and a negative amount?

    Read the code, then choose one answer.

    // A
    if (amount > LIMIT) refuse();
    // B
    if (!(amount > 0 && amount <= LIMIT)) refuse();
    // C
    if (amount <= LIMIT) accept(); else refuse();
    Show the answer: Question 18 of 19

    Answer: B

    A condition that lists what to refuse misses the cases nobody listed. B accepts one range, and everything else is refused, including NaN.

  19. Question 19 of 19 insertIfAbsent(key, value) returns false when the key already exists. The code ignores the return value and sends a reward. Which weakness is this?

    Choose one answer.

    Show the answer: Question 19 of 19

    Answer: CWE-252, an unchecked return value, so the code acts as if the insert had worked

    A10 maps both unchecked return values and unchecked error conditions. Read the result of every call that can say no.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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 catch block fixed approved 2, and the one that allows only on an explicit true approved 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 NaN makes 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.