Error Handling Without Leaks

Useful errors for clients, uninformative ones for attackers, and complete ones for you.

Error responses are a reconnaissance channel. They reveal the stack you run, the structure of your data, which records exist, and sometimes the query that failed.

Three audiences, one response

A client needs enough to fix its request. An attacker should learn nothing. You need everything. The resolution is a request id: the client gets a generic message plus an id, and you log the detail against that id.

What must never reach a client

  • Stack traces — file paths, library versions, internal structure.
  • Database errors — table and column names, and sometimes the query.
  • Internal hostnames, ports and IPs.
  • Which of several checks failed, when that reveals existence.

Existence disclosure

The subtle one. 403 for someone else's record confirms it exists; 404 does not. Distinct messages for "no such user" and "wrong password" turn your login into a user-enumeration API. Choose the answer that reveals nothing, consistently.

Timing is a response too

If "no such user" returns in 5ms and "wrong password" takes 250ms because a hash was computed, the timing is the answer. Always do the same work.

Use a standard shape

RFC 9457 (Problem Details) gives you a conventional JSON error body. The value is consistency: clients handle errors uniformly, and you have one place to enforce what may appear in them.

Never leak in a 500

An unhandled exception must produce a generic body. Set that in the framework's error handler and verify it in production configuration — a debug flag left on is one of the most common findings in an external review.

Example

Example · http
# ❌ Helpful to the wrong audience
HTTP/1.1 500 Internal Server Error
{
  "error": "SequelizeDatabaseError: column \"internal_risk_score\" does not exist",
  "sql": "SELECT id, email, internal_risk_score FROM users WHERE id = 42",
  "stack": "at /app/src/services/UserService.js:142:15\n at ..."
}

# ✅ Generic outward, complete inward
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{
  "type": "https://abc.com/problems/internal-error",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected error occurred.",
  "instance": "/api/users/42",
  "requestId": "01J8XQZ4T7"
}

When to use it

  • A database error message revealing table and column names is replaced by a generic response plus a request id support can trace.
  • A login endpoint stops enumerating users after the responses and timings for unknown-user and wrong-password are made identical.
  • A debug flag left enabled in production is caught by a test asserting that 500 responses never contain a stack trace.

More examples

A central error handler with a safe/unsafe split

The safe flag is the important design choice: it defaults to unsafe, so an error type nobody thought about produces a generic 500 rather than leaking.

Example · javascript
import { randomUUID } from 'crypto';

// Errors we authored and are happy to describe.
export class AppError extends Error {
  constructor(status, code, detail, extra = {}) {
    super(detail);
    Object.assign(this, { status, code, detail, extra, safe: true });
  }
}
export const NotFound = (d = 'Not found') => new AppError(404, 'not_found', d);
export const Forbidden = (d = 'Forbidden') => new AppError(403, 'forbidden', d);
export const BadRequest = (d, extra) => new AppError(400, 'bad_request', d, extra);

// Request id, generated at the edge and echoed everywhere.
app.use((req, res, next) => {
  req.id = req.get('x-request-id') ?? randomUUID();
  res.set('X-Request-Id', req.id);
  next();
});

// The handler. Everything unrecognised becomes a generic 500.
app.use((err, req, res, _next) => {
  const isSafe = err instanceof AppError && err.safe;
  const status = isSafe ? err.status : 500;

  // ALWAYS log the full detail, internally.
  const level = status >= 500 ? 'error' : 'warn';
  logger[level]({
    err: { message: err.message, stack: err.stack, code: err.code },
    requestId: req.id,
    path: req.path,
    method: req.method,
    userId: req.user?.id ?? null,
  }, 'request failed');

  // Never send a body we did not author.
  res.status(status)
     .type('application/problem+json')
     .set('Cache-Control', 'no-store')
     .json({
       type: `https://abc.com/problems/${isSafe ? err.code : 'internal-error'}`,
       title: isSafe ? err.code : 'Internal Server Error',
       status,
       detail: isSafe ? err.detail : 'An unexpected error occurred.',
       instance: req.path,
       requestId: req.id,                     // the one internal detail exposed
       ...(isSafe ? err.extra : {}),
     });
});

// Assert it in production configuration — a debug flag left on is a classic
// external-review finding.
if (process.env.NODE_ENV === 'production') {
  if (process.env.SHOW_STACK_TRACES === 'true' || app.get('env') !== 'production') {
    throw new Error('debug error output must be disabled in production');
  }
}

it('never returns a stack trace', async () => {
  const res = await request(app).get('/api/trigger-error');
  const body = JSON.stringify(res.body);
  expect(body).not.toMatch(/at \/|\.js:\d+|node_modules|SequelizeError|ECONNREFUSED/);
  expect(res.body.requestId).toBeDefined();
});

Not confirming what exists

The timing test is worth having in CI: the dummy-hash fix is easy to remove accidentally during a refactor, and nothing else would notice.

Example · javascript
// ── Registration: do not confirm which emails are registered ─────────
// ❌ "That email is already registered" is a membership oracle.
app.post('/auth/register', async (req, res) => {
  if (await db.users.findByEmail(req.body.email)) {
    return res.status(409).json({ error: 'email_already_registered' });
  }
  // ...
});

// ✅ Same response either way; resolve it by email instead.
app.post('/auth/register', authLimiter, async (req, res) => {
  const existing = await db.users.findByEmail(req.body.email);

  if (existing) {
    // They already have an account — tell THEM, by email, not the caller.
    await sendMail(req.body.email, 'Someone tried to register with your address',
      { resetUrl: `${APP_URL}/forgot` });
  } else {
    await createUserAndSendVerification(req.body);
  }

  // Identical response, identical shape, similar timing.
  res.status(202).json({
    message: 'Check your email to continue.',
  });
});

// ── Login: identical response AND identical work ─────────────────────
const DUMMY_HASH = await argon2.hash('timing-equalisation-placeholder');

app.post('/auth/login', loginLimiter, async (req, res) => {
  const user = await db.users.findByEmail(req.body.email);

  // Hash even when the user does not exist, or the response time is the answer.
  const hash = user?.passwordHash ?? DUMMY_HASH;
  const ok = await argon2.verify(hash, req.body.password).catch(() => false);

  if (!user || !ok) {
    return res.status(401).json({ error: 'invalid_credentials' });   // one message
  }
  // ...
});

// ── Objects: 404, not 403 ────────────────────────────────────────────
// ❌ 403 confirms the record exists
// ✅ 404 for "not yours" and "not there" alike
const invoice = await db.invoices.findOne({ id, userId: req.user.id });
if (!invoice) return res.status(404).json({ error: 'not_found' });

// ── Verify the timing, do not assume it ──────────────────────────────
it('login timing does not reveal whether an account exists', async () => {
  const time = async (email) => {
    const t = process.hrtime.bigint();
    await request(app).post('/auth/login').send({ email, password: 'wrong-password' });
    return Number(process.hrtime.bigint() - t) / 1e6;
  };

  const known = median(await Promise.all(Array.from({ length: 20 },
    () => time('[email protected]'))));
  const unknown = median(await Promise.all(Array.from({ length: 20 },
    () => time('[email protected]'))));

  expect(Math.abs(known - unknown)).toBeLessThan(50);   // milliseconds
});

Validation errors that help without over-sharing

The closing note is the trap: a helpful unique-violation message on registration reintroduces the enumeration leak the 202 response was designed to remove.

Example · javascript
// Validation errors SHOULD be specific — a client cannot fix what it cannot see.
// The line to hold is: describe the INPUT, never the SYSTEM.

// ✅ Specific about what the client sent
{
  "type": "https://abc.com/problems/validation-failed",
  "title": "validation_failed",
  "status": 400,
  "requestId": "01J8XQZ4T7",
  "errors": [
    { "path": "items[0].quantity", "code": "too_big",
      "message": "Must be 100 or fewer" },
    { "path": "email", "code": "invalid_string",
      "message": "Must be a valid email address" },
    { "path": "role", "code": "unrecognized_key",
      "message": "Unexpected field" }
  ]
}

// ❌ Specific about the system
{
  "errors": [
    { "message": "insert into \"orders\" ... violates foreign key constraint
                  \"orders_customer_id_fkey\" on table \"customers\"" },
    { "message": "Cannot read property 'tenantId' of undefined at
                  /app/src/services/OrderService.js:88" },
    { "message": "connect ECONNREFUSED 10.0.1.42:5432" }
  ]
}

// Map known database errors to safe messages rather than passing them through:
function mapDatabaseError(err) {
  switch (err.code) {
    case '23505':   // unique_violation
      return BadRequest('That value is already in use.');
    case '23503':   // foreign_key_violation
      return BadRequest('A referenced record does not exist.');
    case '23514':   // check_violation
      return BadRequest('A value is outside the permitted range.');
    case '22001':   // string_data_right_truncation
      return BadRequest('A value is too long.');
    default:
      return null;  // unknown → generic 500, and log the detail
  }
}

// Note what the mapped messages omit: which column, which constraint, which
// table. "That value is already in use" on a registration form is also, quietly,
// an existence oracle — which is why registration returns 202 regardless.

Discussion

  • Be the first to comment on this lesson.