Skip to content

CWE-209: Generation of Error Message Containing Sensitive Information - Go

Overview

Information disclosure in Go applications happens when error messages, debug output, or diagnostic detail reach the caller instead of the log. An ordinary failure then becomes reconnaissance: the response tells an attacker how the system is put together and what to probe next.

Common sensitive information in error messages includes database query details (table names, column names, SQL syntax errors revealing schema structure), file system paths (/home/app/config/database.yml revealing deployment structure), third-party library versions (identifying known CVEs), stack traces (exposing code organization and internal function names), configuration details (database hostnames, internal IP addresses), and business logic information (account numbers, internal IDs, pricing rules). Development debug output and database connection strings that reach production users leak several of these at once. Panic stack traces belong on that list too, but reach a reader by a different route than the rest: Go's net/http recovers panics itself and writes the trace to the server's error log rather than the response, so the exposure to trace is who can read that log.

The impact varies by information disclosed: SQL error messages containing table/column names enable targeted injection attacks; file paths aid directory traversal attacks; stack traces reveal code structure for reverse engineering; version numbers help attackers find matching exploits; and verbose authentication errors enable username enumeration (distinguishing "invalid username" vs "invalid password"). Attackers combine these leaks with other vulnerabilities to aim an exploit rather than guess at one.

Primary Defence: Return generic error messages to clients and keep the detail in the server-side log. Route every failure through one place that decides what the caller is told, using a custom error type that carries the user-safe message alongside the internal one. Gate any extra verbosity on the environment. Never expose stack traces, database queries, file paths, or system details to end users.

Common Vulnerable Patterns

Database Error Exposure

// VULNERABLE - Exposing database errors to users
package main

import (
    "database/sql"
    "fmt"
    "net/http"

    _ "github.com/go-sql-driver/mysql"
)

var db *sql.DB

func getUserProfile(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("user_id")

    query := "SELECT username, email, role FROM users WHERE id = ?"
    var username, email, role string

    // DANGEROUS: Database error returned directly to user
    err := db.QueryRow(query, userID).Scan(&username, &email, &role)
    if err != nil {
        http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
        return
    }

    fmt.Fprintf(w, "User: %s, Email: %s, Role: %s", username, email, role)
}

// ATTACK:
// GET /profile?user_id=7   (row exists, email column is NULL)
// Response: Database error: sql: Scan error on column index 1, name "email":
//           converting NULL to string is unsupported
//
// GET /profile?user_id=999999
// Response: Database error: sql: no rows in result set
//
// Reveals:
// - Column names and their ordinal positions in the SELECT list
// - Which columns are nullable, and the Go type each is scanned into
// - Whether a given ID exists at all
// - That the response body is built from raw driver error text, so a
//   connection failure or a missing table will leak host names and schema too

Why this is vulnerable: database/sql builds Scan errors from the column metadata the server returned, so the message names the column (name: "email") and its position in the result set - schema disclosure from an ordinary NULL value, with no injection involved. sql: no rows in result set separates "this ID exists" from "this ID does not", which is enough for ID enumeration. Beyond the two shown here, the same %v formatting passes on whatever the driver produces: a Table 'appdb.users' doesn't exist from a mis-deployed migration, or a dial error carrying the database host and port. Note what is not here - a non-numeric user_id produces the same no rows message as a numeric one that is absent, because r.URL.Query().Get returns a string and a string is a valid driver argument. The leak is real; the specific message you get depends on the driver and the data, which is exactly why none of it belongs in a response body.

Panic Stack Traces in Production

// VULNERABLE - Unhandled panics exposing stack traces
import (
    "fmt"
    "net/http"
)

func riskyHandler(w http.ResponseWriter, r *http.Request) {
    data := processUserInput(r.FormValue("input"))

    // DANGEROUS: No panic recovery - the trace goes to the server log and
    // the client's connection is dropped without a status code
    result := data["key"].(string) // Panics if type assertion fails

    fmt.Fprintf(w, "Result: %s", result)
}

func processUserInput(input string) map[string]interface{} {
    return map[string]interface{}{
        "key": 123, // Wrong type
    }
}

// ATTACK:
// POST /process input=anything
//
// CLIENT sees: the connection is closed mid-response.
//   Go's net/http recovers the panic itself, so there is no body and no
//   status line - a client library reports "EOF" or "connection reset".
//
// SERVER LOG gets the whole thing:
// 2026/08/20 17:30:46 http: panic serving 127.0.0.1:50135: interface conversion:
//   interface {} is int, not string
// goroutine 10 [running]:
// net/http.(*conn).serve.func1()
//     /usr/local/go/src/net/http/server.go:1943 +0xd0
// main.riskyHandler({0xe2cc3c, 0x1d9a168}, 0x1d9a008)
//     /home/app/src/main.go:14 +0x1e1
// ...
//
// Reveals, wherever that log is readable:
// - File paths (/home/app/src/main.go)
// - Go toolchain location (/usr/local/go/src/)
// - Function names (riskyHandler)
// - Line numbers (exact code location)
// - Deployment structure

Why this is vulnerable: The dangerous part here is not what the client receives - net/http installs its own recover() per connection, so a panicking handler produces an aborted connection rather than a stack trace in the response body. The trace goes to the server's error log, and that is the exposure to reason about: a log shipped to a shared aggregator, a container's stdout on a multi-tenant platform, or a debug endpoint that echoes recent log lines. It carries absolute file paths (deployment directory structure), function names, line numbers, and the toolchain location. The aborted connection is its own problem - no status code, so a load balancer or client retries against a request that will panic again. Recovering explicitly gives you both halves: a real 500 for the client and a log line you decide the contents of. Note that a framework may not behave like the standard library here; Gin's gin.Recovery() is only registered by gin.Default(), and a router built with gin.New() has no recovery at all.

File System Error Exposure

// VULNERABLE - Exposing file paths in errors
import (
    "fmt"
    "io"
    "net/http"
    "os"
)

func serveConfigFile(w http.ResponseWriter, r *http.Request) {
    configFile := r.URL.Query().Get("config")

    // DANGEROUS: File errors expose full paths
    file, err := os.Open("/etc/myapp/configs/" + configFile)
    if err != nil {
        http.Error(w, fmt.Sprintf("Error: %v", err), http.StatusInternalServerError)
        return
    }
    defer file.Close()

    io.Copy(w, file)
}

// ATTACK:
// GET /config?config=database.yml
//
// Response: Error: open /etc/myapp/configs/database.yml: permission denied
//
// GET /config?config=nope.yml
//
// Response: Error: open /etc/myapp/configs/nope.yml: no such file or directory
//
// Reveals:
// - Absolute path: /etc/myapp/configs/
// - Application deployment location
// - Which files exist: "permission denied" and "no such file or directory"
//   are different answers to "is database.yml there?"

Why this is vulnerable: os.Open errors include the full attempted path, revealing the deployment directory (/etc/myapp/configs/), and the kind of error answers a question the response was never meant to answer: permission denied means the file is there and unreadable, no such file or directory means it is not there. That is file-existence enumeration from error text alone. Note that this handler also concatenates user input straight into a path, which is CWE-22 and the more urgent of the two - a traversal that resolves to a real file does not produce an error at all, it produces the file. Derive what your own template yields before assuming a payload errors: with a base of /etc/myapp/configs/, ../../etc/passwd normalizes to /etc/etc/passwd and fails, while ../../passwd reaches /etc/passwd and succeeds. Fixing the error text without fixing the concatenation leaves the worse bug in place.

Authentication Username Enumeration

// VULNERABLE - Different errors for invalid username vs password
import (
    "fmt"
    "net/http"
)

func loginHandler(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")
    password := r.FormValue("password")

    user, err := getUserByUsername(username)
    if err != nil {
        // DANGEROUS: Reveals username doesn't exist
        http.Error(w, "Invalid username", http.StatusUnauthorized)
        return
    }

    if !verifyPassword(user.PasswordHash, password) {
        // DANGEROUS: Different message - username exists!
        http.Error(w, "Invalid password", http.StatusUnauthorized)
        return
    }

    // Login successful
    createSession(w, user)
}

type User struct {
    Username     string
    PasswordHash string
}

func getUserByUsername(username string) (*User, error) {
    return nil, fmt.Errorf("not found")
}

func verifyPassword(hash, password string) bool {
    return false
}

func createSession(w http.ResponseWriter, user *User) {}

// ATTACK:
// POST username=alice&password=wrong
// Response: Invalid password (Alice exists!)
//
// POST username=bob&password=wrong
// Response: Invalid username (Bob doesn't exist)
//
// Attacker enumerates all valid usernames, then brute-forces only valid accounts

Why this is vulnerable: Different messages for "username doesn't exist" and "password is wrong" let an attacker enumerate valid usernames, which cuts the brute-force space down: rather than working through a million username/password combinations, they confirm a thousand real accounts and attack only those. The same list feeds password spraying - one common password tried against every valid username - and phishing or social engineering aimed at users known to exist. Secure authentication returns the same generic error whether the username or the password was wrong.

Debug Information in Production

// VULNERABLE - Debug mode enabled in production
import (
    "encoding/json"
    "fmt"
    "net/http"
)

var DebugMode = true // DANGEROUS: Left enabled in production

func apiHandler(w http.ResponseWriter, r *http.Request) {
    result, err := processAPIRequest(r)

    if err != nil {
        if DebugMode {
            // DANGEROUS: Detailed debug info in production
            debugInfo := map[string]interface{}{
                "error":          err.Error(),
                "stack":          captureStack(),
                "request_body":   r.Body,
                "database_query": getLastQuery(),
                "env_vars":       map[string]string{"DB_HOST": "db.internal.local"},
            }

            w.Header().Set("Content-Type", "application/json")
            json.NewEncoder(w).Encode(debugInfo)
            return
        }

        http.Error(w, "Internal error", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(result)
}

func processAPIRequest(r *http.Request) (interface{}, error) {
    return nil, fmt.Errorf("database connection failed")
}

func captureStack() string {
    return "stack trace here"
}

func getLastQuery() string {
    return "SELECT * FROM secrets WHERE id = 1"
}

// ATTACK:
// Any API error in production returns:
// {
//   "error": "database connection failed",
//   "stack": "...",
//   "database_query": "SELECT * FROM secrets WHERE id = 1",
//   "env_vars": {"DB_HOST": "db.internal.local"}
// }
//
// Reveals database schema, internal hostnames, code structure

Why this is vulnerable: A debug flag left enabled in production sends the troubleshooting view to whoever triggered the error: stack traces (code structure), database queries (schema and data), environment variables (credentials, internal hostnames), request bodies (potentially sensitive data), and the internal error message. Nothing has to be exploited to get any of it - the endpoint hands it over on any failure. Debug output must be environment-gated, and the gate must default to off.

Secure Patterns

Generic Error Responses with Server-Side Logging

// SECURE - Generic user errors, detailed server logs
package main

import (
    "database/sql"
    "log/slog"
    "net/http"
    "os"
)

var (
    db     *sql.DB
    logger *slog.Logger
)

func init() {
    logger = slog.New(slog.NewJSONHandler(os.Stderr, nil))
}

func secureGetUserProfile(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("user_id")

    query := "SELECT username, email, role FROM users WHERE id = ?"
    var username, email, role string

    err := db.QueryRow(query, userID).Scan(&username, &email, &role)
    if err != nil {
        // SECURE - Detailed logging server-side only
        logger.Error("Database query failed",
            slog.String("user_id", userID),
            slog.String("query_name", "get_user_profile"),
            slog.String("error", err.Error()),
            slog.String("ip", r.RemoteAddr),
        )

        // SECURE - Generic error to user
        http.Error(w, "Unable to retrieve user profile", http.StatusInternalServerError)
        return
    }

    // Return data...
}

// USER sees: "Unable to retrieve user profile"
// SERVER logs: {"level":"ERROR","msg":"Database query failed","user_id":"7","query_name":"get_user_profile","error":"sql: Scan error on column index 1, name \"email\": converting NULL to string is unsupported","ip":"10.0.0.1"}

Why this works: The caller gets "Unable to retrieve user profile" and no detail about databases, queries, or error types. The diagnostics go to a structured server-side log instead, keyed by a query name rather than the raw SQL or its parameters, so troubleshooting still has what it needs. Different database errors - no rows, connection failure, type mismatch - all produce the same user-facing message, so the response no longer separates "this ID exists" from "this ID does not".

Panic Recovery Middleware

// SECURE - Panic recovery preventing stack trace exposure
import (
    "fmt"
    "log/slog"
    "net/http"
    "runtime/debug"
)

func panicRecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                // SECURE - Log panic details server-side, stack included. Capturing
                // it here is the point - recover() discards the goroutine's stack,
                // so without debug.Stack() the log says only what panicked and
                // not where, which is the diagnostic this pattern exists to keep.
                logger.Error("Panic recovered",
                    slog.Any("panic", err),
                    slog.String("stack", string(debug.Stack())),
                    slog.String("path", r.URL.Path),
                    slog.String("method", r.Method),
                    slog.String("ip", r.RemoteAddr),
                )

                // SECURE - Generic error to user (no stack trace)
                http.Error(w, "Internal server error", http.StatusInternalServerError)
            }
        }()

        next.ServeHTTP(w, r)
    })
}

func riskyHandler(w http.ResponseWriter, r *http.Request) {
    data := processUserInput(r.FormValue("input"))
    result := data["key"].(string) // May panic
    fmt.Fprintf(w, "Result: %s", result)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/process", riskyHandler)

    // SECURE - Wrap all handlers with panic recovery
    http.ListenAndServe(":8080", panicRecoveryMiddleware(mux))
}

// PANIC occurs -> USER sees: "Internal server error" (500, no detail)
// SERVER logs: panic value, full stack, and request context

Why this works: The defer recover() pattern catches the panic inside the handler, which is what lets the response be written at all - net/http's own recovery runs one layer out, after the connection is already unsalvageable, so it can only abort. The client gets a real 500 instead of an EOF, which matters for callers and load balancers that treat a dropped connection as retryable. debug.Stack() captures the trace before the deferred function returns and puts it in the log you chose, with the request context you chose to attach, rather than leaving it to the server's default error log in whatever format the runtime picked. That call is load-bearing: recover() stops the unwinding, and everything the panic would otherwise have printed is gone with it, so a recovery middleware without it trades a stack trace for a line saying something failed somewhere. Applied as middleware it covers every handler behind it, so a new route cannot forget it.

Two limits worth knowing before relying on it. A recover() only catches panics on the goroutine that deferred it, so a panic inside a go func() the handler spawned takes the whole process down - see Common Pitfalls. And if the handler already wrote a status line before panicking, http.Error here cannot change it; the client gets the original status with the error text appended, and Go logs superfluous response.WriteHeader call.

Custom Error Types with Safe Messages

// SECURE - Custom errors with user-safe messages
import (
    "crypto/rand"
    "encoding/hex"
    "encoding/json"
    "errors"
    "fmt"
    "log/slog"
    "net/http"
)

// AppError keeps apart three things that are easy to collapse into one: what
// the caller is told, what the log records, and what status is returned.
type AppError struct {
    Code        string // internal classification - logs and metrics, never sent
    Status      int    // HTTP status this error should produce
    Message     string // user-safe: describes the CALLER's situation, not ours
    InternalMsg string // detailed internal message
    Err         error  // wrapped underlying error
}

func (e *AppError) Error() string {
    return e.InternalMsg
}

func (e *AppError) Unwrap() error {
    return e.Err
}

// Error constructors
func newDatabaseError(err error) *AppError {
    return &AppError{
        Code:   "DB_ERROR",
        Status: http.StatusInternalServerError,
        // NOT "a database error occurred". The caller cannot act on which
        // subsystem failed, and telling them there is a database is telling
        // them where to look next.
        Message:     "The request could not be completed. Quote the error ID to support.",
        InternalMsg: fmt.Sprintf("Database operation failed: %v", err),
        Err:         err,
    }
}

func newNotFoundError(resource string) *AppError {
    return &AppError{
        Code:   "NOT_FOUND",
        Status: http.StatusNotFound,
        // Safe to state plainly: it is a fact about the request, not about us.
        Message:     "Resource not found",
        InternalMsg: fmt.Sprintf("%s not found", resource),
        Err:         nil,
    }
}

// newErrorID returns an opaque handle tying a response to its log line.
// Unguessable rather than sequential, so it carries no volume information.
func newErrorID() string {
    b := make([]byte, 8)
    if _, err := rand.Read(b); err != nil {
        return "unavailable"
    }
    return hex.EncodeToString(b)
}

// writeError is the single place an AppError becomes a response.
func writeError(w http.ResponseWriter, appErr *AppError) {
    errorID := newErrorID()

    // SECURE - every diagnostic detail goes here, keyed by the ID the caller gets
    logger.Error("Request failed",
        slog.String("error_id", errorID),
        slog.String("code", appErr.Code),
        slog.String("internal_msg", appErr.InternalMsg),
        slog.Any("error", appErr.Err),
    )

    // SECURE - safe message, the status this error type chose, and the handle
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(appErr.Status)
    json.NewEncoder(w).Encode(map[string]string{
        "error":    appErr.Message,
        "error_id": errorID,
    })
}

func secureHandler(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("user_id")

    user, appErr := getUser(userID)
    if appErr != nil {
        writeError(w, appErr)
        return
    }

    // Process user...
}

func getUser(userID string) (interface{}, *AppError) {
    // Simulated database error
    err := errors.New("connection timeout")
    return nil, newDatabaseError(err)
}

// USER sees: 500 {"error":"The request could not be completed. Quote the error
//                 ID to support.","error_id":"a3f19c02b7d45e88"}
// SERVER logs: error_id="a3f19c02b7d45e88" code="DB_ERROR"
//              internal_msg="Database operation failed: connection timeout"

Why this works: The error type carries the response's three independent decisions - message, status, and what gets logged - so a constructor has to settle all three at once, and writeError is the only place any of them reaches the client. Adding an error class means adding a constructor, which means being asked what a caller should be told.

The user-facing message describes the caller's situation, never the server's. "Resource not found" is a fact about the request and is safe to state. "A database error occurred" is a fact about your architecture: it confirms a database sits behind this endpoint, tells an attacker that a payload reaching it produced a server-side failure rather than a validation rejection, and separates "the query broke" from "the template broke" - a distinction worth having when you are deciding what to probe next. It also gives a legitimate user nothing, because there is no action they can take differently. The same objection applies more strongly to sending the internal Code: DB_ERROR is the machine-readable version of the same sentence, which is why it is logged and not serialized. The generic page lists the stronger form of this as a pitfall - returning PSQLException or a vendor error code - and the coarse version is the same leak with less precision.

What replaces it is the error ID. An opaque handle costs the caller nothing, lets support find the exact log line, and reveals nothing at all - which is what makes it safe to hand out on every failure, including the ones you have not thought of yet.

Carrying Status on the error is what keeps the sanitization from also flattening the response codes. It is easy to write this pattern with a hard-coded http.StatusInternalServerError at the single point where the response is written - it looks tidy, and every test asserting "the error text is generic" still passes - but a NOT_FOUND constructor whose error leaves as a 500 is a lie to the caller and to your own alerting.

One thing this does not decide for you: whether "not found" is the right answer. Where the resource exists but belongs to someone else, returning 404 rather than 403 is often the deliberate choice, because a 403 confirms the record exists - see CWE-863. That is an authorization decision, and this type just carries whichever one you made.

Uniform Authentication Errors with Timing Safety

// SECURE - Uniform authentication errors preventing enumeration
import (
    "log/slog"
    "net/http"

    "golang.org/x/crypto/bcrypt"
)

func secureLoginHandler(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")
    password := r.FormValue("password")

    // Always perform password verification work even if user doesn't exist
    user, _ := getUserByUsername(username)

    var storedHash []byte
    if user != nil {
        storedHash = []byte(user.PasswordHash)
    } else {
        // Precomputed bcrypt hash for a dummy password
        storedHash = []byte("$2a$10$wJ9N4l0X0h2F0D0tNVk9ze6w77BYw0rQyE1PP0z6oH64mHkcw8w2u")
    }

    // Always verify a bcrypt hash so missing users follow a similar path
    valid := verifyPassword(storedHash, password)

    if !valid || user == nil {
        // SECURE - Same error message for all authentication failures
        logger.Info("Failed login attempt",
            slog.String("username", username),
            slog.String("ip", r.RemoteAddr),
        )

        http.Error(w, "Invalid credentials", http.StatusUnauthorized)
        return
    }

    logger.Info("Successful login",
        slog.String("username", username),
        slog.String("ip", r.RemoteAddr),
    )

    createSession(w, user)
}

func verifyPassword(hash []byte, password string) bool {
    return bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
}

// ATTACK ATTEMPT:
// POST username=alice&password=wrong  -> "Invalid credentials"
// POST username=bob&password=wrong    -> "Invalid credentials"
// Identical messages, similar verification path - no practical enumeration signal

Why this works: The same error message ("Invalid credentials") is returned whether the username doesn't exist, password is wrong, or account is locked. Timing differences are reduced by always performing bcrypt verification even when the user doesn't exist, using a precomputed dummy hash. This does not make the whole login path mathematically constant-time, so pair it with rate limiting and monitoring; what it removes is the easy signal - the differing response text and the obvious timing gap.

Environment-Aware Error Handling

// SECURE - Environment-based error verbosity
import (
    "encoding/json"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "runtime/debug"
)

// Opt IN to verbosity. An unset, misspelled or empty ENV yields false, so a
// deployment that configures nothing gets the quiet branch.
var isDevelopment = os.Getenv("ENV") == "development"

type ErrorResponse struct {
    Message string                 `json:"message"`
    Code    string                 `json:"code"`
    ErrorID string                 `json:"error_id"`
    Details map[string]interface{} `json:"details,omitempty"`
}

// What the caller is allowed to know, per internal error class. Keeping the
// three together means a new class cannot be added without deciding its
// status and its public wording at the same time.
type publicError struct {
    status  int
    code    string
    message string
}

var internalError = publicError{http.StatusInternalServerError, "INTERNAL_ERROR", "An internal error occurred"}

var publicErrors = map[string]publicError{
    "NOT_FOUND":  {http.StatusNotFound, "NOT_FOUND", "Resource not found"},
    "AUTH_ERROR": {http.StatusUnauthorized, "AUTH_ERROR", "Authentication failed"},
    // Deliberately indistinguishable from any other server-side failure: from
    // outside, a database outage and a template panic are the same 500.
    "DB_ERROR": internalError,
}

func handleError(w http.ResponseWriter, err error, code string) {
    public, ok := publicErrors[code]
    if !ok {
        public = internalError
    }

    // newErrorID is the helper from the previous section.
    errorID := newErrorID()

    response := ErrorResponse{
        Message: public.message,
        Code:    public.code,
        ErrorID: errorID,
    }

    if isDevelopment {
        // DEVELOPMENT ONLY: none of this may reach a real caller
        response.Message = err.Error()
        response.Details = map[string]interface{}{
            "stack":         string(debug.Stack()),
            "type":          fmt.Sprintf("%T", err),
            "internal_code": code,
        }
    }

    // Always log diagnostic details server-side, under the ID the caller has
    logger.Error("Request error",
        slog.String("error_id", errorID),
        slog.String("code", code),
        slog.String("error", err.Error()),
        slog.String("stack", string(debug.Stack())),
    )

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(public.status)
    json.NewEncoder(w).Encode(response)
}

// PRODUCTION, handleError(w, err, "DB_ERROR")   -> 500
// {"message":"An internal error occurred","code":"INTERNAL_ERROR","error_id":"a3f19c02b7d45e88"}
//
// PRODUCTION, handleError(w, err, "NOT_FOUND")  -> 404
// {"message":"Resource not found","code":"NOT_FOUND","error_id":"5c7e0d1a94b2f306"}
//
// DEVELOPMENT, handleError(w, err, "DB_ERROR")  -> 500
// {"message":"sql: no rows in result set","code":"INTERNAL_ERROR","error_id":"...",
//  "details":{"stack":"...","type":"*errors.errorString","internal_code":"DB_ERROR"}}

Why this works: Server-side logging is identical in both environments, so production keeps full diagnostic capability; only what leaves the process changes. The environment check tests for development rather than for production, so an unset or misspelled ENV selects the quiet branch - every way of getting the variable wrong becomes a way of getting the safe behaviour rather than the verbose one.

The internal code and the public code are separate fields, and that separation is the point. NOT_FOUND and AUTH_ERROR describe the caller's situation, so they cross the boundary unchanged and carry a status the caller can act on. DB_ERROR describes ours, so it stays in the log and the caller is told INTERNAL_ERROR - the same thing they would be told for a template failure or a nil dereference. An error code sent to a client is an API contract for the caller's own branching, not a summary of what broke; if a code would only ever be read by a human wondering what your stack looks like, it belongs in the log line instead. The error_id is what preserves the operational value: support can find the exact record, and the response still says nothing.

The status comes out of the same table as the message. Writing w.WriteHeader(http.StatusInternalServerError) unconditionally is the easy version of this function and it produces {"code":"NOT_FOUND","message":"Resource not found"} with a 500 attached - a body and a status that contradict each other, which nothing in a "the response is generic" test would catch.

Common Pitfalls

  • Panic recovery middleware only guards the request goroutine: a panic inside a goroutine spawned by a handler (go func() { ... }()) is not caught by the handler's own defer recover() - it crashes the whole process, and whatever gets written to stdout/stderr during the crash can end up in operator-visible logs or a generic upstream 502 page before the process restarts, rather than the sanitized response the middleware was meant to guarantee.
  • fmt.Errorf("%w", err) wrapping still carries the original message text: wrapping an error to preserve errors.Is/errors.As chains does not sanitize it - wrappedErr.Error() still contains the underlying driver or library message, so returning it to a client after wrapping leaks exactly what returning the unwrapped error would have.
  • An errors.As switch that handles known cases but falls through to err.Error() for the default branch: mapping specific sentinel/typed errors to safe messages looks complete, but any error type the switch doesn't explicitly recognize (a new driver error, a third-party library change) falls through to the raw message instead of a generic one.
  • Structured logging fields that still land in an HTTP-visible sink: slog.String("error", err.Error()) is safe when the handler writes to a file or a log aggregator, but the same logger instance reused to write directly into an HTTP response (a debug endpoint, a health-check handler that echoes its own logger output) reintroduces the same leak the middleware was built to prevent.

Additional Resources