Web Security

Secure Error Handling: Preventing Information Disclosure

Why verbose error messages are a serious vulnerability, how to structure user-facing versus developer-facing errors, patterns for generic error responses in Express and Next.js, and what information is safe to return to clients.

November 1, 20259 min readShipSafer Team

Why Error Messages Are a Security Vulnerability

Error messages are one of the most common sources of information disclosure vulnerabilities. A stack trace returned to the browser reveals your technology stack, file paths, library versions, and sometimes database schema details. A validation error that says "Invalid email address — no account with this email exists" enables user enumeration. A database error that leaks the SQL query tells an attacker exactly what injection payload to craft next.

Information disclosure is categorized as a standalone vulnerability type in OWASP's Top 10 (it falls under "Security Misconfiguration" and "Sensitive Data Exposure") and is routinely found in bug bounty programs and penetration tests. The remediation is straightforward: never return internal error details to clients.

The Two Audiences for Error Information

Every error your application generates has two potential audiences with different needs:

The user needs to know:

  • That something went wrong
  • What they can do about it (retry, contact support, check their input)
  • A reference identifier so they can report the issue

The developer needs to know:

  • The exact error message and type
  • The full stack trace
  • The request context (user, endpoint, parameters)
  • The internal identifier to correlate with logs

These two needs must be served by two different channels. Users see a generic, sanitized message. Developers see the full detail in server-side logs, linked by a correlation ID.

What Verbose Errors Reveal

Stack Traces

A stack trace reveals your technology stack and internal structure:

Error: Cannot read property 'email' of undefined
    at getUserProfile (/app/src/services/user.service.ts:47:23)
    at async POST /api/user/profile (node_modules/next/dist/server/router.js:284:17)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)

This tells an attacker: Node.js application, TypeScript, Next.js, the service file path, and the exact line number where a null reference occurs. The attacker now knows to submit requests that might trigger null references in user service lookups.

Database Errors

MongoServerError: E11000 duplicate key error collection: production.users index: email_1 dup key: { email: "attacker@example.com" }

This reveals: MongoDB, the database name (production), the collection name (users), the indexed field (email), and that this email address is already registered (user enumeration).

SQL Errors

ERROR 1064 (42000): You have an error in your SQL syntax near '''; SELECT * FROM users WHERE id=1 OR '1'='1' at line 1

This confirms an SQL injection attempt and reveals the database is MySQL.

Validation Errors with User Enumeration

{
  "error": "Invalid credentials: no account found with email john@example.com"
}

vs.

{
  "error": "Invalid email or password"
}

The first enables attackers to enumerate valid email addresses. The second does not reveal whether the email exists.

Building a Secure Error Response Pattern

Correlation IDs

Every request should be assigned a unique ID that ties the user-facing error to the server-side log entry:

import { randomUUID } from 'crypto';

// Middleware: assign request ID
app.use((req, res, next) => {
  req.requestId = randomUUID();
  res.setHeader('X-Request-ID', req.requestId);
  next();
});

When an error occurs, log the full details with the request ID, then return the request ID to the client as a reference:

{
  "error": "An unexpected error occurred. Please try again.",
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}

Support staff can look up the request ID in the log aggregator to see the full error context.

Error Classification

Not all errors are equal. Distinguish between:

Client errors (4xx): The client did something wrong. Safe to be descriptive, as the information only helps the legitimate client correct their request.

// Acceptable detail for 400 Bad Request
{
  "error": "Validation failed",
  "fields": {
    "email": "Must be a valid email address",
    "password": "Must be at least 8 characters"
  }
}

But even here, avoid details that enable enumeration:

// NEVER: user enumeration
{ "error": "No account found with this email" }

// SAFE: same message for wrong email and wrong password
{ "error": "Invalid email or password" }

Server errors (5xx): Your system failed. Return no detail about why.

// Safe 500 response
{
  "error": "An unexpected error occurred. If this continues, please contact support.",
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}

Authorization errors: Return 403 (or 404 when you don't want to reveal resource existence) without detail about why access was denied. Never reveal whether a resource exists to unauthorized requesters.

// Good: reveals nothing about why
{ "error": "Access denied" }

// Bad: reveals authorization logic
{ "error": "You need admin role to access this resource" }

// Worse: reveals resource existence to unauthorized user
{ "error": "You don't have permission to view order #12345" }

Express.js Error Handling

Express uses a four-parameter middleware function as the error handler. It must be defined after all routes and other middleware:

// Global error handler - must be last middleware
app.use((err, req, res, next) => {
  const requestId = req.requestId || 'unknown';

  // Log full error with context for developers
  logger.error('Unhandled error', {
    requestId,
    error: {
      message: err.message,
      stack: err.stack,
      code: err.code,
      name: err.name,
    },
    request: {
      method: req.method,
      path: req.path,
      userId: req.user?.userId,
      ip: req.ip,
    }
  });

  // Classify the error and determine safe response
  if (err.name === 'ValidationError' || err.type === 'validation') {
    return res.status(400).json({
      error: 'Invalid request data',
      requestId,
    });
  }

  if (err.name === 'UnauthorizedError' || err.status === 401) {
    return res.status(401).json({
      error: 'Authentication required',
      requestId,
    });
  }

  if (err.status === 403) {
    return res.status(403).json({
      error: 'Access denied',
      requestId,
    });
  }

  if (err.status === 404) {
    return res.status(404).json({
      error: 'Not found',
      requestId,
    });
  }

  // All unclassified errors become generic 500s
  res.status(500).json({
    error: 'An unexpected error occurred. Please try again or contact support.',
    requestId,
  });
});

Ensure Express is not in development mode in production—app.set('env', 'production') prevents Express from sending stack traces in error responses by default.

Next.js Error Handling

Next.js has several layers where errors must be handled:

Server Actions

Following the pattern from CLAUDE.md and security best practices, server actions should never leak error details:

'use server';

import { logger } from '@/lib/logger';

export async function updateUserProfile(userId: string, data: ProfileInput) {
  try {
    // ... operation
    return { success: true, data: result };
  } catch (error) {
    // Full context logged server-side only
    logger.error('Failed to update user profile', {
      error: error instanceof Error ? error.message : 'Unknown error',
      stack: error instanceof Error ? error.stack : undefined,
      userId,
    });

    // Generic message returned to client
    return {
      success: false,
      error: 'Failed to update profile. Please try again.',
    };
  }
}

API Route Handlers

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const requestId = request.headers.get('x-request-id') || crypto.randomUUID();

  try {
    // ... fetch user
    return NextResponse.json({ data: user });
  } catch (error) {
    logger.error('GET /api/users/[id] failed', {
      requestId,
      userId: params.id,
      error: error instanceof Error ? error.message : 'Unknown',
    });

    return NextResponse.json(
      { error: 'An unexpected error occurred', requestId },
      { status: 500 }
    );
  }
}

Custom Error Pages

Next.js error.tsx files handle React component errors:

// app/error.tsx
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  // Note: error.digest is a Next.js-generated hash (safe to show)
  // Never render error.message or error.stack in production
  return (
    <div>
      <h2>Something went wrong</h2>
      <p>An unexpected error occurred. Please try again.</p>
      {error.digest && (
        <p className="text-sm text-muted-foreground">
          Error reference: {error.digest}
        </p>
      )}
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Next.js's error.digest is a hash identifier that maps to the server-side log entry without exposing the actual error content.

Environment-Conditional Error Detail

Some teams want richer errors in development without exposing them in production. This is acceptable but requires explicit checks:

const isDevelopment = process.env.NODE_ENV === 'development';

export function buildErrorResponse(error: unknown, requestId: string) {
  const base = {
    error: 'An unexpected error occurred',
    requestId,
  };

  if (isDevelopment && error instanceof Error) {
    return {
      ...base,
      // Development-only fields—never reach production
      _dev: {
        message: error.message,
        stack: error.stack,
      }
    };
  }

  return base;
}

Caution: this pattern requires that your NODE_ENV is reliably set to production in production environments. Many incidents have resulted from NODE_ENV accidentally being set to development or left unset on production servers.

HTTP Response Codes as Information

Response status codes themselves can be informative. A consistent policy:

  • 404 vs 403: Return 404 (not found) when you don't want to reveal that a resource exists to the unauthorized user. Return 403 (forbidden) only when it is acceptable to confirm the resource exists but the user lacks access. For private user resources (profile, orders), prefer 404 to unauthorized users.

  • 401 vs 403: 401 means "not authenticated" (no valid credentials provided). 403 means "authenticated but not authorized" (we know who you are, but you can't do this). Returning 401 when the user is authenticated but lacks permission reveals more than necessary.

  • 500 vs 503: 500 is an unhandled error; 503 is a deliberate maintenance or capacity response. Do not return 503 in error handlers for unexpected errors.

Security Headers for Error Pages

Even error pages should include security headers:

  • X-Content-Type-Options: nosniff — prevents the browser from interpreting error response bodies as executable content
  • Content-Security-Policy — even on error pages, especially if they render user-supplied data (which they should not, but defense in depth)

The combination of correlation IDs, server-side full logging, and generic client-facing messages gives you the operational visibility you need without handing attackers a roadmap. The extra five minutes to implement this pattern in your error handling middleware saves hours in incident response and prevents vulnerabilities from being exploited in the first place.

error-handling
information-disclosure
nodejs
nextjs
appsec
web-security

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.