How-to › Release and secure packages

How to keep internal detail out of API error responses#

Map every unhandled exception to an RFC 9457 problem with a correlation id, run the mapper everywhere, and scan responses for your own host names and table names.

Audience
API producer
Level
intermediate
Topic
Harden clients and servers
Languages
TypeScript and JavaScript
Verified

A caller posts a malformed filter and your API answers with the SQL it tried to run, the table name, and the file and line that built the query. The same service names an internal host when an upstream times out. Nothing in the code chose to say any of that; the framework’s default error handler said it for you.

What you get

You will end up with one error mapper that turns any throw into a problem document with a correlation id in place of internal detail, and a scan that proves it. This is for you if your API’s unhandled exceptions reach callers.

Short answer

Register one error handler, last, that turns anything a route throws into an RFC 9457 problem document. Give it a stable type, a safe title, the status, and an instance carrying a correlation id, and write the message and stack to your log under the same id. Pass through only the errors you defined for callers. Run that mapper in every environment, because a framework default that prints the stack outside production teaches staging nothing.

You will need

An API whose unhandled exceptions reach callers, and Node 22 or later for the sample, which drives three failures through Express. Verified 2026-10-21 against Node 22.22.0 and Express 5.3.0. The document format is RFC 9457, which says the detail member ought to help the client correct the problem rather than give debugging information.1 The taxonomy, the codes, and the retry semantics belong to design error responses an agent can act on; here the question is only what must not appear.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A framework production error handlerA service with no error contract yet, where a bare status code is acceptableA body that depends on an environment string, so staging and production answer one fault differently, and no id to find the log line withCallers need to report a failure you can then find
An explicit RFC 9457 mapperAny API whose callers are programs, and which keeps a log worth correlatingEvery error a caller may learn about has to be declared, and an undeclared one becomes a bare 500The API already has an error format its clients parse
Sentry beforeSend scrubbingKeeping secrets and personal data out of the error trackerRuns after the response has left, so it protects your dashboards and never the caller’s screenYou have no tracker, or the leak you fear is in the response

The framework default is a decision made by an environment variable, the stack in development and a status message in production. Neither body carries an identifier. The mapper costs a list of declared errors and pays back a response a caller can quote to you that says only what you chose to say. Sentry’s hook solves a different problem: the copy of the error that reaches a third party, after the caller has read theirs.

Declare what a caller may learn

Start from the short list of errors a caller is entitled to understand, each with a type, a title, a status, and a detail written for them. The mapper lets these through and nothing else, so a code path nobody declared cannot leak by omission.

export class ProblemError extends Error {
  constructor({ type, title, status, detail, ...extensions }) {
    super(detail ?? title)
    this.name = this.constructor.name
    this.problem = { type, title, status, detail, ...extensions }
  }

  toProblem() {
    return { ...this.problem }
  }
}

export class NotFound extends ProblemError {
  constructor(what, id) {
    super({
      type: 'https://api.meterco.example/problems/not-found',
      title: `${what} not found`,
      status: 404,
      detail: `No ${what} has the id ${id}.`,
    })
  }
}

The detail says which meter was missing, because the caller supplied the id and can act on it. A validation error carries its field list as an extension member, which is where caller-facing structure belongs. The driver error, the timeout, and the bug are not on this list, which is the point of having one.

Map every throw, last in the chain

One function builds two documents from whatever was thrown. The caller’s problem carries an instance of the form urn:uuid:..., which the RFC allows as an opaque occurrence identifier. The log record carries the same id with the message, the stack, and the cause.

// A client error raised by Express's own middleware, such as a body express.json() could
// not parse, carries a 4xx status and expose: true. It keeps the status and its standard
// title, and its message goes to the log like any other.
function clientError(err) {
  const status = err?.status ?? err?.statusCode
  if (err?.expose !== true || !Number.isInteger(status) || status < 400 || status > 499) return null
  return { type: 'about:blank', title: STATUS_CODES[status], status }
}

export function describeFailure(err, id = randomUUID()) {
  const safe = err instanceof ProblemError ? err.toProblem() : (clientError(err) ?? INTERNAL)
  const problem = { ...safe, instance: `urn:uuid:${id}` }
  const record = {
    id,
    status: problem.status,
    name: err?.name ?? typeof err,
    message: err?.message ?? String(err),
    stack: err?.stack,
    cause: err?.cause,
  }
  return { problem, record }
}

// Express error middleware: the last thing registered, so every throw a route does not
// catch arrives here, including the ones this file has never heard of.
export function problemHandler(log) {
  return (err, req, res, next) => {
    const { problem, record } = describeFailure(err)
    log(record)
    // Headers already on the wire cannot be replaced; let Express close the connection.
    if (res.headersSent) return next(err)
    res.status(problem.status).type('application/problem+json').send(JSON.stringify(problem))
  }
}

err.message is read into the record and never into the problem, because the message is where a driver puts the query and a client puts the host name. One kind of undeclared error keeps its status: a client error Express itself marks with expose: true, such as a body express.json() could not parse. A malformed request is then a 400 rather than a 500.

Express 5 forwards a rejected promise from a route to this middleware, as its migration guide describes, so an async handler that throws lands here without a wrapper. The headersSent check follows the Express guide: once a response has started streaming, the default handler closing the connection is the only safe move left.

node demo.mjs
response 500 application/problem+json; charset=utf-8
{"type":"about:blank","title":"Internal Server Error","status":500,"instance":"urn:uuid:0513dca5-dbc0-4ec5-8cd1-f96324a9c103"}
log record
  id:       0513dca5-dbc0-4ec5-8cd1-f96324a9c103
  name:     DatabaseError
  message:  relation "meter_readings" does not exist
  query:    query: SELECT value FROM meter_readings WHERE site = 'north'
  stack:    12 lines, kept here and nowhere else

The caller gets a status, the registered about:blank type, and an id. The engineer who receives that id gets the query, the table, and twelve lines of stack, through whatever access your logs already have.

Drive every failure you own and read the answers

Drive each failure through each handler and read the bodies the way an attacker would, for the things your own service knows and a caller should not. The patterns are a table name, a query, a host name, a stack frame, a source path, a dependency path, and an exception class.

export const PATTERNS = {
  'table name': /meter_readings/,
  query: /SELECT .* FROM/,
  hostname: /svc\.cluster\.local/,
  'stack frame': /\bat .*\(/,
  'source path': /\.mjs:\d+/,
  'dependency path': /node_modules\//,
  'exception class': /TypeError|DatabaseError|UpstreamError/,
}
node scan.mjs
handler                route                  status  content type              leaks
default, development   /meters?site=north     500     text/html                 table name, query, stack frame, source path, dependency path, exception class
default, development   /invoices/inv_42/pdf   500     text/html                 hostname, stack frame, source path, dependency path, exception class
default, development   /readings/latest       500     text/html                 stack frame, source path, dependency path, exception class
default, production    /meters?site=north     500     text/html                 none
default, production    /invoices/inv_42/pdf   500     text/html                 none
default, production    /readings/latest       500     text/html                 none
problem mapper         /meters?site=north     500     application/problem+json  none
problem mapper         /invoices/inv_42/pdf   500     application/problem+json  none
problem mapper         /readings/latest       500     application/problem+json  none

The development default leaks everything the error knew, because its body is err.stack, and finalhandler, which Express delegates to, switches to the status message only when its environment is exactly production.2 The production default gives the caller a status in HTML and no way to refer to the failure. The mapper gives them an id, in the media type their client already parses. Add your own patterns from your inventory, your schema, and your lockfile, and run the scan in the test suite.

Repeat the mapper in the other two languages

The shape is the same in Python and Go, and so is the trap. Starlette, which FastAPI runs on, wraps the app in a ServerErrorMiddleware that returns the plain text Internal Server Error with a 500, and a traceback page instead when debug is set. A FastAPI exception handler registered for Exception is the mapper, returning a JSONResponse in the problem shape.

Go’s net/http recovers a panic in a handler, logs the stack to the server’s error log, and closes the connection, so a panic leaks nothing and answers nothing.3 The leak in Go is written by hand: http.Error(w, err.Error(), 500) copies a driver’s message, host and all, into the body. A recover middleware that writes the problem document and logs the error under one id replaces both habits.

Check it worked

Run the suite, which drives the three failures through both handlers and reads every body.

node --test problem.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 272.160603

Test 4 asserts application/problem+json and an empty leak list for every failure behind the mapper. Test 5 asserts the instance in the body equals the id on the log record. Test 6 pins the pitfall: the framework default carries a stack frame in development and no id in production. Test 7 sends a body Express cannot parse and gets a 400 problem with nothing else in it.

When it goes wrong

Staging answers a fault with the stack and production answers it with a problem document. The mapper was wired behind a condition on the environment, so production is the first place anyone sees its output.

node pitfall.mjs
staging     500 text/html                 leaks: table name, stack frame, source path, dependency path, exception class
production  500 application/problem+json  leaks: none

Run the mapper everywhere and move the verbosity to the log, where the id finds it. The first production incident is a poor time to find the mapper’s own bug.

A missing record comes back as a bare 500 rather than a 404. The lookup threw the driver’s own not-found error, which is not a ProblemError, so the mapper did what it does with strangers. Throw NotFound from the repository function that knows what missing means, and leave the mapper strict.

The connection drops with no body at all. The error was thrown after the handler had started streaming, so the headers were already sent and the mapper handed the error back to Express, which closes the connection. Validate and fetch before the first write, so a failure still has a response to live in.

When not to do this

Do not return err.message for an error you did not define. The message is where a driver prints the query and a client prints the host, and a dependency upgrade can change what it says without a line of your code changing.

Do not take production’s silence as the control. The default handler’s status message hides the detail and the id with it, so a caller who hits the fault has nothing to report. A bare 500 that cannot be traced is a different failure, not a safe one.

Do not scrub in the tracker and call the response safe. Sentry’s beforeSend is, in its own words, the last chance to edit an event before it is sent, and the event is sent after the response. Server-side scrubbing runs later still. Both guard a copy of the error, not the original the caller read.

Do not strip the detail out of validation errors in the name of safety. A field name and the reason it failed are what the caller needs to fix the request, which is what the RFC says detail is for. The allowlist in errors.mjs is where that detail lives.4

Last verified

Verified 2026-10-21 against Node 22.22.0 and Express 5.3.0, with finalhandler 2.1.1 beneath it. Every output block is what the command preceding it printed, against the Express app in app.mjs on a loopback port. The database and the upstream are stand-ins that throw what a driver and a client would. Sentry, Starlette, FastAPI, and Go’s net/http were read from their documentation and source and not run.

Footnotes

  1. The advice on detail turns on the word “ought” and not on any keyword RFC 9457 imports from BCP 14 alongside its MUST and SHOULD. A stack trace in that member therefore breaks no requirement of the document. The security considerations come closer, encouraging generators to avoid making implementation details such as a stack dump available through the HTTP interface. Encouragement is the strongest verb the section spends on the subject. ↩︎ Back to text

  2. The whole decision rests on one string. The finalhandler README says the body will be the HTML of the status code message if env is production, otherwise err.stack, with env read from NODE_ENV unless overridden. Express sets it from the same variable with development as the fallback, so a server nobody configured is a server in development, by definition rather than by intent. ↩︎ Back to text

  3. Go’s package exports ErrAbortHandler, a sentinel value to panic with on purpose. Any panic aborts the response to the client; this one also stops the server writing a stack trace to its log. It is the one panic in the package whose entire purpose is to say less. ↩︎ Back to text

  4. The OWASP API Security Top 10 lists error messages that include stack traces among the signs of a misconfigured API, under its eighth entry. The project’s error handling cheat sheet recommends the problem details format, citing it in the body as RFC 7807, the document RFC 9457 obsoleted, while its reference list names 9457. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.