How do I handle errors without leaking information?

Direct answer

Return a generic message and a correlation ID to the caller; log the detail server-side. Stack traces, database errors and file paths in responses give an attacker a map of your stack. And keep responses identical whether or not a record exists, or the error message becomes an enumeration tool.

Muhammad HasanUpdated

Generic outward, detailed inward

app.use((err, req, res, next) => {
  const id = crypto.randomUUID();
  logger.error({ id, err, path: req.path, user: req.user?.id });
  res.status(err.status || 500).json({
    error: 'Something went wrong',
    reference: id,
  });
});

The caller gets a reference they can quote to support. You get everything, tied to that reference.

What must not appear in a response: stack traces, file paths, database error text, framework versions, SQL fragments, internal hostnames, other users' data in a validation message.

Debug mode is the big one

Most of the serious cases aren't handwritten error handlers, they're a framework's debug page left enabled.

Laravel's Ignition page shows environment variables including database credentials. Django's debug page shows settings and query history. Rails shows source and locals. Flask's Werkzeug debugger offers an interactive console, which is remote code execution.

APP_DEBUG=false, DEBUG = False, config.consider_all_requests_local = false, debug=False. Check the deployed configuration rather than the file in the repository.

Enumeration through differences

Errors leak by varying, not only by being verbose.

// Leaks which accounts exist
if (!user) return res.status(404).json({ error: 'User not found' });
if (!valid) return res.status(401).json({ error: 'Wrong password' });

Same response for both. Same status code, same body, similar timing. And on login, verify a dummy hash when the user doesn't exist, or the response time answers the question your message didn't.

Same principle on password reset ("if an account exists, we've sent an email"), on registration, and on any lookup by an identifier.

404 rather than 403

When someone requests a record they don't own, 403 confirms it exists. 404 doesn't.

const order = await db.orders.findOne({ id, userId: req.user.id });
if (!order) return res.status(404).end();

One query, one response, no distinction between "doesn't exist" and "isn't yours".

Validation errors

The one place detail is genuinely useful, "email is required" helps and reveals nothing. Keep it to the shape of the input, not the state of the data. "That email is already registered" is an enumeration oracle wearing a helpful hat.

Client-side too

Source maps in production expose your original source. Verbose console logging exposes internal structure. Error tracking tools capture request bodies by default, which is how tokens end up in a third-party service. Configure scrubbing.

Testing it

Trigger errors deliberately in staging: malformed JSON, a bad database credential, a missing record, an unauthorised access. Read the responses. This is quick and it's the only way to know what your stack actually returns rather than what you configured.

Go deeper

Kolega for AppSec teams

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free