Subscribe

Express 5 errors: no stack, map unknown to 500

An open desk drawer stuffed with torn coral-edged papers, house style.

A10:2025 is new on the OWASP Top 10. I opened the Mishandling of Exceptional Conditions page on 22 August 2026. CWE-209 sits in that bucket: an error message that still contains sensitive information. Express 5.2.1 published on 1 December 2025 still writes err.stack to the client when NODE_ENV is not production.

Express 5 already forwards a rejected async route into the handler. The control is what that handler writes, and what the process does when a promise never reaches it. Pair this page with Express merge, fetch, and eval for the other Node sinks, with Helmet for the header pack, and with injection when a driver string still reaches SQL.

A10 is the 2025 name for a leaking 500

A10:2025 collected 24 CWEs. The ones this page cares about are CWE-209, CWE-550, CWE-636 failing open, and CWE-756 missing a custom error page. Scenario 2 on that OWASP page is a database error that reveals the full system text so the next request can be a better injection. The old habit of res.send(err) is that scenario.

CWE-532 is the sibling in A09:2025 Security Logging and Alerting Failures: the same secret lands in the log file instead of the response. A09 and A10 are two sinks for one object. The object is err plus the request that produced it.

I opened the Express 5.x error handling guide. The built-in handler writes err.stack unless the environment is production. In production it writes the HTML status text. res.statusCode is taken from err.status or err.statusCode. A value outside 4xx and 5xx becomes 500. That last sentence is the map this page will write by hand, because a custom JSON helper often forgets it.

Express 5 now forwards a rejected promise

The 5.x guide is explicit. An async route that returns a Promise calls next(value) when it rejects or throws. You no longer wrap every handler in try/catch or install express-async-errors. cipheredStones was pointing at that paragraph in April 2023, when 5.x was still labeled beta.

app.get("/tickets/:id", async function readTicket(req, res) {
  const row = await loadTicket(req.params.id, req.actor.orgId);
  if (!row) {
    throw new AppError(404, "not found");
  }
  res.json(toPublicTicket(row));
});

That is the happy path. loadTicket throws a driver error, Express calls sendError. You do not catch it in the route just to res.json(err).

The leftover holes are the ones the guide still names:

  • A promise you did not return. doWork().then(...) without return is invisible to Express. Attach .catch(next) or return the chain.
  • A callback API such as fs.readFile. First-argument errors are not thrown. Pass them to next(err) yourself.
  • A timer. setTimeout(() => { throw err }, 0) runs after the handler returns. Catch inside the timer and call next(err).

Express 5 did not make vm safe and it did not make a merge safe. Those stay on the JavaScript backend page. This page is the path from a thrown value to a status code.

Unknown status becomes 500

Libraries set err.status for convenience. Some set 200. Some set 0. Some set 999. The 5.x default treats anything outside 4xx and 5xx as 500. Your JSON helper must do the same, or a 200 with a stack key becomes a successful leak.

class AppError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "AppError";
    this.status = status;
  }
}

function mapStatus(err) {
  const raw = Number(err && (err.status || err.statusCode) || 500);
  if (Number.isInteger(raw) && raw >= 400 && raw <= 599) return raw;
  return 500;
}

AppError is the only type you throw on purpose for a 4xx. A Zod failure you mapped becomes new AppError(400, "invalid_body"). A missing row becomes 404. A driver error stays a plain Error. mapStatus then returns 500 because that object has no trusted status.

Do not copy err.status from a dependency you did not review. Do not send 401 or 403 for a failure you did not classify. Those codes are existence oracles when the id was guessed. Prefer 404 on a scoped miss. The IDOR page is that WHERE clause. Here the rule is: unknown becomes 500.

One object. Two sinks. The client never sees the tree.
ROUTE    throw AppError(404, "not found")
         or a driver Error with no status

MAP      mapStatus(err)
         400-599 stay
         anything else becomes 500

CLIENT   4xx: the message you wrote
         500: "request failed"
         never err.stack

SERVER   logError(err, redactReq(req))
         stack stays here
         Cookie and password stripped

The client gets a generic body

sendError is the named fallback. Four arguments are how Express recognizes it. Mount it last. If headers are already gone, delegate with next(err) so the built-in closer can end the socket.

const GENERIC = "request failed";

function sendError(err, req, res, next) {
  if (res.headersSent) return next(err);
  const status = mapStatus(err);
  const client = status < 500 && err instanceof AppError ? err.message : GENERIC;
  logError(err, req, status);
  res.status(status).json({ error: client });
}

app.use(sendError);

A 4xx you constructed may keep err.message if that string is yours: invalid_body, not found, forbidden. A 500 never includes the Postgres detail, the file path, the query string, or the stack. CWE-550 is the server-generated variant of that leak. The OWASP Error Handling Cheat Sheet is the same rule in longer form.

Set NODE_ENV=production in the process supervisor even when sendError is mounted. The built-in page still runs if you call next(err) twice or forget headersSent. A laptop that exports production by accident hides the stack you wanted while debugging. Keep the variable next to the service file. Keep sendError mounted either way.

Do not render err into an HTML template. Do not put err.stack behind a query flag such as ?debug=1. A flag the client can set is not an allowlist.

The log gets the tree, never the secret

A09:2025 wants the event. CWE-532 forbids the password in that event. Log reqId, method, path, status, err.name, err.message, and err.stack on the server. Leave Cookie, Authorization, password fields, and the session id out of that line.

const SECRET_KEYS = new Set([
  "authorization",
  "cookie",
  "set-cookie",
  "password",
  "passwd",
  "token",
  "access_token",
  "refresh_token",
  "secret",
  "api_key",
  "apikey",
]);

function redactReq(req) {
  const headers = {};
  for (const [key, value] of Object.entries(req.headers || {})) {
    headers[key] = SECRET_KEYS.has(key.toLowerCase()) ? "[redacted]" : value;
  }
  const body = {};
  if (req.body && typeof req.body === "object" && !Array.isArray(req.body)) {
    for (const [key, value] of Object.entries(req.body)) {
      body[key] = SECRET_KEYS.has(key.toLowerCase()) ? "[redacted]" : value;
    }
  }
  return {
    reqId: req.id,
    method: req.method,
    path: req.path,
    headers,
    body,
  };
}

function logError(err, req, status) {
  const rec = {
    status,
    name: err && err.name,
    message: err && err.message,
    stack: err && err.stack,
    req: redactReq(req),
  };
  console.error(JSON.stringify(rec));
}

redactReq and logError are the named pair. Call them only from sendError and from the process listeners below. A second logger that prints req wholesale will put the cookie back. Grep for console.log(req and console.error(err, req).

Alert on a burst of 500s with the same name. Alert on a 500 that mentions timeout after a deploy. Do not alert on every 404. A09 is the alerting half. This page will not invent a SIEM vendor.

Events sendError never sees

I opened the current Node process page. unhandledRejection fires when a Promise rejects and no handler is attached in that turn. If you do not listen, modern Node raises that rejection as an uncaught exception. The default of --unhandled-rejections is throw. A fire-and-forget sendMail(user) with no await and no .catch is that event. Express never saw it.

function exitOnOrphan(reason) {
  const err = reason instanceof Error ? reason : new Error(String(reason));
  console.error(JSON.stringify({
    name: err.name,
    message: err.message,
    stack: err.stack,
    orphan: true,
  }));
  process.exit(1);
}

process.on("unhandledRejection", (reason) => {
  exitOnOrphan(reason);
});

process.on("uncaughtException", (err) => {
  exitOnOrphan(err);
});

Log, then exit. A process that continues after an unknown throw has already lost its invariants. The supervisor starts a clean worker. Do not swallow the event so the dashboard stays green. Do not put req on this path. There is no request.

A10’s fail-closed note applies to half-finished writes. If you debit then credit then log, a throw after the debit rolls the whole unit back. Attempting to resume a half row is how you invent a second bug. That is a transaction boundary, not a sendError feature. The handler still maps the leftover throw to 500.

Prove the handler on your origin

You are not walking an exploit. You are proving sendError omitted the stack and mapStatus collapsed a junk code.

const assert = require("node:assert/strict");

assert.equal(mapStatus({}), 500);
assert.equal(mapStatus({ status: 200 }), 500);
assert.equal(mapStatus({ status: 999 }), 500);
assert.equal(mapStatus({ status: 404 }), 404);
assert.equal(mapStatus(new AppError(400, "invalid_body")), 400);

const fakeReq = {
  id: "t1",
  method: "POST",
  path: "/login",
  headers: { cookie: "__Host-session=s", authorization: "Bearer x" },
  body: { email: "ada@your-app.example", password: "dummy" },
};
const redacted = redactReq(fakeReq);
assert.equal(redacted.headers.cookie, "[redacted]");
assert.equal(redacted.headers.authorization, "[redacted]");
assert.equal(redacted.body.password, "[redacted]");
assert.equal(redacted.body.email, "ada@your-app.example");

Then throw from a route you own and read the JSON. Expect {"error":"request failed"} and no stack key on a 500. Expect 404 and not found from AppError.

curl -sS -D - "https://your-app.example/this-route-500s"
# Expect: HTTP/2 500
# Expect body: {"error":"request failed"}
rg -n "err\\.stack|res\\.json\\(\\{[^}]*stack|console\\.log\\(req|console\\.error\\(err, req" \
  --glob '!node_modules'

A hit is a review. A Docker image that forgets NODE_ENV still needs sendError. The grep is how you find the helper that put the tree back.

Questions we keep getting

Can I echo err.message on a 500 in production?

No. Driver text includes table names, SQL fragments, and file paths. Send request failed. Keep the real string in logError.

Does NODE_ENV=production replace sendError?

No. Production only changes the built-in HTML. A JSON API you wrote can still print the stack. Mount sendError. Set the variable anyway so a missed headersSent path stays quiet.

Should I keep the process up after unhandledRejection?

No. Log with exitOnOrphan, then exit. A worker that continues after an unknown reject has already lost its state. The supervisor replaces it.

Aphinya Dechalert

Aphinya Dechalert / About Author

Aphinya is a skilled technical writer with field experiences in software development, agile, and JavaScript full stack with AWS and Google cloud. She is a developer advocate and community builder, helping others navigate their journeys and careers as developers.