11 min read

How to handle errors in production (without leaking your secrets)

When your AI-built app errors in production, the temptation is to log everything so you can debug. The result: most error logs in vibe-coded apps leak API keys, JWTs, password hashes, and customer PII into log aggregators that anyone with read access can grep. Here is the right pattern.

Your app is shipping errors in production. You opened Sentry / Better Stack / your console logs and pasted the stack traces. You didn't notice that the request body included an API key. The log aggregator now has your OpenAI key in plain text, indexed, searchable, and accessible to anyone with read access on your account.

This is the right pattern for error handling in AI-built apps in 2026. What to log, what to redact, where the canonical leaks happen.

TL;DR

1. Log structured data, not stringified objects. Structured = filterable; strings = leaks. 2. Redact secrets at the logging boundary, not inside individual handlers. Defense in depth. 3. Never log request bodies, response bodies, or full headers without redaction. 4. Set up Sentry / Better Stack / similar BEFORE production launch. Free tiers exist; the post-incident value is unbounded. 5. Your error boundary should fail-closed (return generic error) and log internally, not show stack traces to users.

The 5 secret-leak patterns in error logs

### Pattern 1 — stringified request objects

// WRONG
try {
  await processPayment(req);
} catch (e) {
  console.error("payment failed", { req, error: e });
  // req.headers.authorization, req.body.cardNumber, etc. all in the log
}

The req object includes headers (Authorization: Bearer ..., Cookie with session JWT) and body (potentially payment info, passwords from a login attempt, etc.). The error log now has all of it.

The fix is logging only the safe fields:

// RIGHT
try {
  await processPayment(req);
} catch (e) {
  console.error("payment failed", {
    method: req.method,
    path: req.url,
    user_id: session?.id,  // safe — UUID, not PII
    error: e instanceof Error ? e.message : String(e),
  });
}

Explicit allowlist of fields. Whatever's not in the allowlist doesn't get logged.

### Pattern 2 — response body in error context

// WRONG
try {
  const result = await fetch("https://api.openai.com/v1/chat/completions", {
    headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
    body: JSON.stringify(payload),
  });
  if (!result.ok) {
    console.error("openai error", { result });
    // result.headers includes the request's Authorization header on some
    // runtimes; result.body if you call .text() before logging
  }
}

If result includes echo'd headers OR if you call result.text() and log the response body when the API errored, you can leak credentials, system prompts, and customer data into your error logs.

The fix:

// RIGHT
if (!result.ok) {
  console.error("openai error", {
    status: result.status,
    statusText: result.statusText,
    // Carefully — the body may include error context safe to log,
    // but inspect the API's error shape first. Some APIs echo
    // request headers in errors.
  });
}

### Pattern 3 — environment variable dumps

// WRONG
if (process.env.DEBUG) {
  console.log("env", process.env);
  // Every env var in plain text. OPENAI_API_KEY, STRIPE_SECRET_KEY,
  // DATABASE_URL with password, JWT_SECRET — all in the log.
}

This is the worst pattern. AI tools sometimes generate it as a "debug helper" you forget to remove.

The fix is never logging the entire env. If you need to verify env vars are set:

// RIGHT
console.log("env check", {
  has_openai: Boolean(process.env.OPENAI_API_KEY),
  has_stripe: Boolean(process.env.STRIPE_SECRET_KEY),
  has_db: Boolean(process.env.DATABASE_URL),
  // Boolean only — never the values themselves
});

### Pattern 4 — JWT in URL parameters

OAuth flows sometimes pass tokens in URL parameters (legitimately, in the callback step). If you log the full URL of an error, the JWT is in the log:

// WRONG
console.error("auth callback failed", { url: req.url });
// req.url is something like "/auth/callback?code=...&state=...&id_token=eyJ..."

The fix: parse out the parameter names, log them as a set, never the values:

// RIGHT
const url = new URL(req.url);
console.error("auth callback failed", {
  path: url.pathname,
  param_names: Array.from(url.searchParams.keys()),
  // never the param values
});

### Pattern 5 — full database errors

Postgres errors include the query that failed. The query may include parameter values:

// WRONG
try {
  await db.query("UPDATE users SET email = $1 WHERE id = $2", [email, userId]);
} catch (e) {
  console.error("db error", { error: e });
  // e.query and e.parameters might be in the log, including the email
}

Some DB clients include the query + parameters in the error object. Logging the raw error logs them.

The fix:

// RIGHT
try {
  await db.query("UPDATE users SET email = $1 WHERE id = $2", [email, userId]);
} catch (e) {
  console.error("db error", {
    table: "users",
    operation: "update",
    error_code: e.code,
    error_message: e instanceof Error ? e.message : String(e),
    // Never log query text + parameters together
  });
}

The redaction layer pattern

Per-handler redaction is fragile. The reliable pattern is a redaction layer at the logging boundary that strips secrets from anything passing through.

```ts // lib/log.ts import "server-only";

const SECRET_PATTERNS: Array<[string, RegExp]> = [ ["aws.akid", /AKIA[0-9A-Z]{16}/g], ["stripe", /(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}/g], ["github", /gh[psour]_[A-Za-z0-9]{36,}/g], ["openai", /sk-[A-Za-z0-9]{40,}/g], ["anthropic", /sk-ant-[A-Za-z0-9_\-]{90,}/g], ["jwt", /eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}/g], ["postgres", /postgres(?:ql)?:\/\/[^:\s]+:[^@\s]+@[^\s]+/g], ];

function redact(text: string): string { let out = text; for (const [, pattern] of SECRET_PATTERNS) { out = out.replace(pattern, "[REDACTED]"); } return out; }

export function logError(message: string, context: Record<string, unknown>) { const safe = JSON.parse(JSON.stringify(context, (_, v) => typeof v === "string" ? redact(v) : v )); console.error(redact(message), safe); } ```

This redaction layer mirrors Securie's MemorySanitizer (which redacts the same patterns from LLM-prompt-bound memos). Same regex set, same defense logic. Use one for memos, the other for logs; consistent coverage.

Sentry / Better Stack / LogTail — set up BEFORE launch

The first incident is when you'll wish you had error monitoring. Set up Sentry (or Better Stack, LogTail, or similar) before launch:

npm install @sentry/nextjs

Sentry's SDK has built-in PII / secret scrubbing — by default it strips Authorization headers, password fields, and credit card numbers. Verify the scrubbing is on for your config (Sentry dashboard → Settings → Security & Privacy → "Data Scrubbing" enabled).

Even with Sentry's scrubbing, the redaction layer above is defense in depth. The scrubbing catches obvious patterns; your redaction layer catches the patterns specific to your providers.

What about debugging without logging?

You'll resist the redaction discipline because you want to debug. Three patterns:

### Pattern 1 — local-only verbose logging

// only log full request when running locally
if (process.env.NODE_ENV === "development") {
  console.log("full request", { req });
}

Local development is fine — secrets are your own dev secrets, the logs aren't aggregated, the surface is bounded.

### Pattern 2 — request ID + correlation

Generate a request ID at the entry of every request. Include it in every log line for that request. When something errors, the request ID lets you correlate logs without logging the request body.

const requestId = crypto.randomUUID();
console.log("request received", { requestId, path: req.url });
// ... in the handler, error case ...
console.error("request failed", { requestId, error: e.message });

Combined with structured logging (Sentry / Better Stack), you can search by request ID and see the full trace.

### Pattern 3 — production debugging via session replay

For complex bugs that require seeing the user's flow, session replay tools (Sentry Replay, LogRocket, Datadog Session Replay) let you watch what the user did. They have built-in PII scrubbing for inputs; verify that's on.

The trade is privacy — session replay records user actions. The implementation must comply with your privacy policy.

Error boundaries — fail closed

Your app's error boundaries (React error boundary, Next.js error.tsx) should fail-closed:

```tsx // app/error.tsx "use client";

export default function Error({ error, reset }) { return ( <div> <h2>Something went wrong</h2> <p>We've logged this and are looking into it.</p> <button onClick={() => reset()}>Try again</button> {/* DO NOT render error.message — may contain secrets, internals */} </div> ); } ```

Generic error message to the user. Stack trace + error context goes to your error monitoring (Sentry), not to the user's screen.

The opposite pattern — showing the stack trace to the user — leaks file paths, library versions, sometimes JWTs, and gives attackers reconnaissance information.

Stop checking these manually

The 5 secret-leak patterns above are tool-agnostic — Cursor, Lovable, Bolt, Copilot all generate code with at least one of them. Manual code review catches some; structural defenses catch all.

Securie's secret_scanner specialist (Day-1 production-validated) detects every leaked credential pattern in your code, including in logging statements that would leak. Live-validates against the provider API. Auto-rotates on supported providers (Indie tier and up).

The MemorySanitizer / log-redaction symmetry: Securie applies the same defense-in-depth secret-redaction patterns at every system boundary that touches an external service (LLM prompts, error logs, audit trails). Use the same patterns in your code; the corpus is in the secret-scanner glossary entry.

Related

Related posts