Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

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

Utilities
Quick take
Typical fix time 20 min
  • 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
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 lists "Security Logging and Monitoring Failures" (A09) as a top-10 risk partly because organisations often expose this information without realising it.

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
}
Development exceptions are fine — the risk is production

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

  1. 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.
  2. Search the codebase for patterns like res.json(error), res.send(err.stack), and JSON.stringify(error) in route handlers, and confirm each is guarded in the way the OWASP Error Handling Cheat Sheet (opens in new tab) recommends.
  3. Check that the server logs contain the full stack trace searchable by the correlation ID returned to the client.
  4. Verify that the X-Powered-By header 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.

Sources

References used to support the guidance in this rule.

Rules that often go hand-in-hand with this one.

Integrate real-time error monitoring in production

A real-time error monitoring service captures, groups, and alerts on unhandled exceptions and promise rejections in production so issues are discovered before users report them.

Testing
Implement a content security policy

A Content Security Policy is implemented to prevent XSS attacks and control resource loading.

Security
Leaked Environment Variables

Checks for exposed API keys, tokens, passwords, and other secrets embedded in HTML source, JavaScript bundles, or client-accessible files.

Security
Audit dependencies for known vulnerabilities

Dependencies are regularly scanned for known security vulnerabilities using automated tooling, and critical findings are remediated before deployment.

Security

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385