Prevent stack trace exposure in production error responses
Production error responses never include stack traces, internal file paths, framework internals, or other debugging detail that could aid an attacker (OWASP A09).
- Never return raw error objects or stack traces in API responses
- Log full error details server-side; send only a generic message to the client
- Use a central error handler to ensure consistent sanitisation across all routes
- Assign correlation IDs so support teams can match client-visible errors to server logs
Rule Details
When something goes wrong on the server, the full error detail is essential for debugging, but the OWASP Error Handling Cheat Sheet (opens in new tab) is explicit that it belongs in server logs and monitoring systems, not in the response body seen by the user or an attacker.
Code Example
A typical unhandled Express error exposes:
{
"error": "Error: ENOENT: no such file or directory, open '/app/config/secrets.yml'\n at Object.openSync (node:fs:590:18)\n at Object.readFileSync (node:fs:475:35)\n at loadConfig (/app/src/lib/config.ts:23:18)\n at Object.<anonymous> (/app/src/routes/settings.ts:41:22)",
"stack": "Error: ENOENT: no such file or directory...",
"code": "ENOENT",
"path": "/app/config/secrets.yml"
}This single response reveals:
- The server runs Node.js
- The app framework and file structure
- The path to a secrets configuration file
- The exact library versions (via frame line numbers)
Why It Matters
Stack traces reveal file paths, function names, library versions, and sometimes database schema or configuration details. An attacker uses this information to identify the exact version of a framework or ORM, look up known CVEs for that version, and craft a targeted exploit. OWASP A09 (opens in new tab) highlights how often organisations expose this information without realising it.
The Correct Pattern
// ✅ Only the correlation ID reaches the client
{
"error": "An unexpected error occurred.",
"requestId": "req_7f3a9b2c"
}
// ✅ Full details are logged server-side, searchable by requestId
// [ERROR] req_7f3a9b2c: Error: ENOENT: no such file or directory, open '/app/config/secrets.yml'
// at loadConfig (/app/src/lib/config.ts:23:18)Centralised Error Handler: Express.js
// middleware/error-handler.ts
import type { Request, Response, NextFunction } from 'express'
import { randomUUID } from 'crypto'
import * as Sentry from '@sentry/node'
export interface AppError extends Error {
statusCode?: number
code?: string
isOperational?: boolean // true = expected error (validation, 404); false = bug
}
export function errorHandler(
error: AppError,
req: Request,
res: Response,
_next: NextFunction
) {
const requestId = (req.headers['x-request-id'] as string) ?? randomUUID()
const statusCode = error.statusCode ?? 500
// Log full details server-side
if (statusCode >= 500) {
console.error({
requestId,
method: req.method,
url: req.url,
statusCode,
message: error.message,
stack: error.stack,
code: error.code,
})
// Report to monitoring service
Sentry.withScope((scope) => {
scope.setTag('requestId', requestId)
scope.setContext('request', { method: req.method, url: req.url })
Sentry.captureException(error)
})
}
// Return sanitised response — no stack trace, no internal details
const isProduction = process.env.NODE_ENV === 'production'
const clientMessage =
isProduction || !error.isOperational
? getGenericMessage(statusCode)
: error.message // Operational errors (e.g., validation) can include a message
res.status(statusCode).json({
error: clientMessage,
requestId, // Allows support to correlate with server logs
})
}
function getGenericMessage(statusCode: number): string {
if (statusCode === 400) return 'Invalid request.'
if (statusCode === 401) return 'Authentication required.'
if (statusCode === 403) return 'You do not have permission to perform this action.'
if (statusCode === 404) return 'The requested resource was not found.'
if (statusCode === 429) return 'Too many requests. Please try again later.'
return 'An unexpected error occurred. If the problem persists, contact support.'
}// app.ts
import express from 'express'
import { errorHandler } from './middleware/error-handler'
const app = express()
// ... routes ...
// Must be the last middleware registered
app.use(errorHandler)Next.js API Routes
// lib/api-error.ts
export class ApiError extends Error {
constructor(
public statusCode: number,
message: string,
public isOperational = true
) {
super(message)
this.name = 'ApiError'
}
}
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError
}// lib/with-error-handler.ts
import { randomUUID } from 'crypto'
import { NextResponse } from 'next/server'
import * as Sentry from '@sentry/nextjs'
import { isApiError } from './api-error'
type RouteHandler = (req: Request, ...args: unknown[]) => Promise<NextResponse>
export function withErrorHandler(handler: RouteHandler): RouteHandler {
return async (req, ...args) => {
const requestId = randomUUID()
try {
return await handler(req, ...args)
} catch (error) {
const isProd = process.env.NODE_ENV === 'production'
if (isApiError(error) && error.isOperational) {
// Operational error — safe to return the message
return NextResponse.json(
{ error: error.message, requestId },
{ status: error.statusCode }
)
}
// Unexpected error — log and return generic message
console.error({ requestId, error })
Sentry.captureException(error, { tags: { requestId } })
return NextResponse.json(
{
error: isProd
? 'An unexpected error occurred.'
: (error instanceof Error ? error.message : String(error)),
requestId,
},
{ status: 500 }
)
}
}
}// app/api/users/[id]/route.ts
import { withErrorHandler } from '@/lib/with-error-handler'
import { ApiError } from '@/lib/api-error'
export const GET = withErrorHandler(async (req, { params }) => {
const { id } = await params
const user = await getUser(id)
if (!user) {
throw new ApiError(404, 'User not found')
}
return NextResponse.json({ user })
})React Error Boundaries (Client Side)
The same principle applies on the frontend — never render raw error objects:
// components/error-boundary.tsx
'use client'
import { Component, type ErrorInfo, type ReactNode } from 'react'
interface State { hasError: boolean }
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
state = { hasError: false }
static getDerivedStateFromError(): State {
return { hasError: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Log server-side (or to monitoring) — never render to DOM
console.error('Uncaught error:', error, info.componentStack)
}
render() {
if (this.state.hasError) {
// ✅ Generic user-facing message, no error details
return (
<div role="alert">
<h2>Something went wrong.</h2>
<p>Please refresh the page or contact support if the problem persists.</p>
</div>
)
}
return this.props.children
}
}Framework-Level Configuration
Some frameworks expose errors by default in development:
// Express — disable x-powered-by and error details in production
app.disable('x-powered-by')
if (process.env.NODE_ENV === 'production') {
app.set('env', 'production') // Suppresses stack traces in default error handler
}// next.config.js — generate source maps but don't serve them publicly
module.exports = {
productionBrowserSourceMaps: false, // Default; keep false unless you host maps privately
}Stack traces in development error responses are extremely useful. The goal is to ensure the NODE_ENV === 'production' check is in place so verbose errors are stripped before the code reaches production. Use separate environment configurations and never deploy with NODE_ENV=development.
Exceptions
- Scanner output, leaked-secret detections, or stack traces should be confirmed as production-relevant before being escalated as blockers.
- Archived dependencies, sample values, or test fixtures can create false positives, but they should still be documented and bounded clearly.
- If multiple findings overlap, prioritize the issue that most directly enables compromise or data exposure.
Verification
- Trigger a deliberate 500 error in production (or a production-like staging environment) and confirm the response body contains only a generic message and a correlation ID — no stack, no file paths.
- Search the codebase for patterns like
res.json(error),res.send(err.stack), andJSON.stringify(error)in route handlers, and confirm each is guarded in the way the OWASP Error Handling Cheat Sheet (opens in new tab) recommends. - Check that the server logs contain the full stack trace searchable by the correlation ID returned to the client.
- Verify that the
X-Powered-Byheader is absent from HTTP responses (it leaks the framework version).
Use with AI
Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.
Check
Verify implementation
Check whether production API error responses include stack traces, file paths, or internal implementation details.
Fix
Auto-fix issues
Implement a central error handler that logs full details server-side and returns only a sanitised, generic error message to the client.
Explain
Learn more
Explain what information stack traces reveal and how attackers use that information to identify and exploit vulnerabilities.
Review
Code review
Review error handlers, catch blocks, and API response code. Flag any location where error.stack, error.message (raw), or internal paths are serialised directly into a response body.