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