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/nextjsSentry'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
Your launch tweet went viral, or you got featured on Hacker News, or a YouTuber linked to your demo. Now 50,000 people are visiting in an hour and your app is dying. Here is the playbook for surviving the first traffic spike — what fails first, what to fix in the moment, and how to prepare for next time.
It's 3 AM. You scrolled X and saw a tweet about a Lovable / Bolt / v0 app leaking customer data. You start wondering if yours is next. Here is the exact checklist to run in the next 30 minutes — what to check, what to fix first, and how to stop having this problem.
A solo founder's API key got scraped from a public commit and used to run gpt-4 calls for two days before they noticed. Total damage: $4,217. Here is the postmortem — how the key leaked, how to detect this, and how to prevent it from happening to you.
A prospect just emailed asking 'is your app secure?' You don't have a real answer. Here is the honest playbook — what to say, what evidence to point at, and how to turn this question from a deal-stopper into a deal-accelerator. Written for solo founders who don't want to lie.