Errors, security, and observation
Design error middleware
Place one final four-argument error handler that maps known failures and hides internal details.
Error middleware is the one function every failed request ends in. It serves two audiences with opposite needs. The user needs a short, stable answer. The operator needs the stack, the request id, and the route. Each side gets its own information and nothing from the other.
Four arguments, registered last
Express recognizes error middleware by its four parameters, (err, req, res, next). It only runs when an error was thrown or passed to next(), and it goes after every route, including the 404 handler:
export function errorHandler(err, req, res, next) {
if (res.headersSent) {
return next(err)
}
const status = err.status ?? err.statusCode ?? 500
if (status >= 500) {
console.error({ requestId: req.id, method: req.method, url: req.originalUrl, err })
}
const message = status >= 500 ? 'Internal error' : err.message
if (req.originalUrl.startsWith('/api/')) {
return res.status(status).json({ error: message, requestId: req.id })
}
res.status(status).send(renderErrorPage({ status, message, requestId: req.id }))
}
And in createApp(), the last line before return app:
app.use(errorHandler)
Let’s walk through the decisions.
Check headersSent first
If a response already started streaming and then something threw, we can’t send a new status. Passing the error to next() hands it to the default Express handler, which closes the connection.
Classify by status
Our HttpError classes carry a status. The body parser errors carry statusCode. Everything else, a TypeError, a database rejection, a bug, has neither and falls to 500.
Anything below 500 is the client’s fault, so its message is safe to show. Note not found helps the user. Anything 500 and up is our fault, and the message is replaced. connection refused to 10.0.3.12:5432 tells an attacker about your network and the user nothing useful.
Log only the server failures
A 404 for a mistyped URL is not an incident, and logging every one buries the real problems. The 500s get the full object with the request id, so when a user reports the id from their error page you find the exact request.
One shape per audience
The API gets JSON with two keys, always the same two: error and requestId. The pages get an HTML error page with the same values. Compare it to the Express default, which sends a stack trace as HTML in development and the bare status text in production. Neither is a contract.
Watch the two sides diverge
Trigger each failure and compare the response with the terminal:
| Cause | Client sees | Log shows |
|---|---|---|
| empty title | 400 title is required | nothing |
| someone else’s note | 403 Forbidden | nothing |
| missing note | 404 Note not found | nothing |
| database down | 500 Internal error + id | full stack, same id |
If the fourth row ever shows the stack in the client column, the handler has a bug. That row is the test I never delete.
Try this: throw a plain new Error('boom') from a route and confirm the client gets Internal error while the terminal gets boom and the stack.
Lesson completed