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

Source: https://voxgig.com/howto/keep-internal-detail-out-of-error-responses

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://www.rfc-editor.org/rfc/rfc9457.html), 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](/howto/error-responses-an-agent-can-act-on); here
the question is only what must not appear.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A framework production error handler](https://expressjs.com/en/guide/error-handling/) | A service with no error contract yet, where a bare status code is acceptable | A body that depends on an environment string, so staging and production answer one fault differently, and no id to find the log line with | Callers need to report a failure you can then find |
| [An explicit RFC 9457 mapper](https://www.rfc-editor.org/rfc/rfc9457.html#section-3) | Any API whose callers are programs, and which keeps a log worth correlating | Every error a caller may learn about has to be declared, and an undeclared one becomes a bare 500 | The API already has an error format its clients parse |
| [Sentry `beforeSend` scrubbing](https://docs.sentry.io/platforms/javascript/guides/node/configuration/filtering/) | Keeping secrets and personal data out of the error tracker | Runs after the response has left, so it protects your dashboards and never the caller's screen | You 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.

```js title="errors.mjs"
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](https://www.rfc-editor.org/rfc/rfc9457.html#section-3.2), 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](https://www.rfc-editor.org/rfc/rfc9457.html#section-3.1.5). The
log record carries the same id with the message, the stack, and the cause.

```js title="problem.mjs"
// 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](https://expressjs.com/en/guide/migrating-5/) describes, so an `async` handler
that throws lands here without a wrapper. The `headersSent` check follows the
[Express guide](https://expressjs.com/en/guide/error-handling/): once a response has started
streaming, the default handler closing the connection is the only safe move left.

```bash
node demo.mjs
```

```text output
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.

```js title="scan.mjs"
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/,
}
```

```bash
node scan.mjs
```

```text output
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`](https://github.com/pillarjs/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`](https://github.com/encode/starlette/blob/master/starlette/middleware/errors.py)
that returns the plain text `Internal Server Error` with a 500, and a traceback page instead
when `debug` is set. A FastAPI
[exception handler](https://fastapi.tiangolo.com/tutorial/handling-errors/) registered for
`Exception` is the mapper, returning a `JSONResponse` in the problem shape.

Go's [`net/http`](https://pkg.go.dev/net/http#Handler) 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.

```bash
node --test problem.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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](https://docs.sentry.io/security-legal-pii/scrubbing/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]

## Related how-tos

- [Design error responses an autonomous agent can act on](/howto/error-responses-an-agent-can-act-on)

- [Translate upstream API errors behind your own API](/howto/translate-upstream-errors-behind-your-api)

- [Review an API against the OWASP API Security Top 10](/howto/review-an-api-against-owasp-api-top-ten)

- [Verify every error response matches one schema](/howto/verify-every-error-response-matches-one-schema)

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

[^1]: The advice on `detail` turns on the word "ought" and not on any keyword
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html#section-3.1.4) 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](https://www.rfc-editor.org/rfc/rfc9457.html#section-5)
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.

[^2]: The whole decision rests on one string. The
[`finalhandler` README](https://github.com/pillarjs/finalhandler) 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.

[^3]: Go's package exports [`ErrAbortHandler`](https://pkg.go.dev/net/http#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.

[^4]: The [OWASP API Security Top 10](https://api-security.owasp.org/editions/2023/en/0xa8-security-misconfiguration/)
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](https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html)
recommends the problem details format, citing it in the body as RFC 7807, the document RFC 9457
obsoleted, while its reference list names 9457.