Your API Keys Are in Your Logs: HTTP Client Errors Leak Credentials

We found this in our own codebase during a security review, in the most ordinary line of code imaginable:
this.logger.error("QBO token refresh failed", err);
The integration call had failed, someone had written a sensible log line, and the result was an OAuth refresh token and a client secret written to stdout in plain text. Not once: the review found the same pattern at 59 call sites across 13 files. Nothing was misconfigured and no library was misused. This is what those libraries do by default.
What actually leaks when you log an HTTP error?
The error object from an HTTP client carries a copy of the request you sent, including the
Authorization header, the request body and the URL with its query string. Most loggers print
every property of an error, so your credentials go into the log. The response you were debugging
is the small part; the request you already knew about is the dangerous part.
An axios error, for example, carries:
err.config.headers— yourAuthorizationheader: API key, bearer token, basic auth.err.config.data— the request body. On an OAuth token call, that's the refresh token and client secret.err.config.urlandparams— anything you pass as?api_key=….err.request— the underlying request object, with the same headers again.
Which loggers leak it?
We reproduced this with a throwaway local server that answers 401, a real axios call carrying a
marker token, and each logger's default configuration. "Leaks" means the marker token appeared in
the output.
| What you write | Leaks your credentials |
|---|---|
console.error(err) | Yes |
console.error("msg", err) | Yes |
pino — log.error(err) or log.error({ err }, "msg") | Yes |
winston — logger.error(err) or logger.error("msg", err) | Yes |
JSON.stringify(err) | Yes |
err.toJSON() (axios) | Yes — the returned object includes config |
console.error(err.message) | No |
`${err}` / String(err) | No |
err.stack | No |
Node prints an error by inspecting it, and an inspected object includes its own enumerable
properties. pino and winston serialize errors the same way, for the same reason: those extra
properties are usually the useful context. With an HTTP client, they're your credentials.
This isn't a flaw in axios, pino or winston. Each is doing the reasonable thing with the data it's given. The mistake is ours: we hand the logger an object that contains our secrets.
Reproduce it in your own project
Paste this into a file and run it. If your client and logger behave like ours, you'll see the token printed back at you.
// leak-check.mjs — does your logger print your Authorization header?
import { createServer } from "node:http";
import axios from "axios";
const server = createServer((req, res) => { res.writeHead(401); res.end('{"error":"nope"}'); });
await new Promise((r) => server.listen(0, "127.0.0.1", r));
try {
await axios.get(`http://127.0.0.1:${server.address().port}/v1/thing`, {
headers: { Authorization: "Bearer LEAK-CANARY-123" },
});
} catch (err) {
console.error("call failed", err); // ← the line under test
}
server.close();
Search the output for LEAK-CANARY-123. Then swap console.error for whatever your application
actually uses, including your logging framework's error serializer, and run it again.
How to find every place it happens
Two searches find nearly all of it. The first finds errors passed to a logger, the second finds errors serialized by hand:
rg -n --type ts '(logger|console|log)\.(error|warn|info)\([^)]*\b(err|error|e)\b'
rg -n --type ts 'JSON\.stringify\(\s*(err|error|e)\b|\.toJSON\(\)'
Then look at each hit and ask one question: can the value reaching the logger be an error from an
HTTP client? If the call is wrapped in a try around an outbound request, assume yes.
Also check the places that log without an error object: request tracing middleware that logs headers, retry wrappers that log the config they're about to retry, and any "debug mode" that dumps a request.
How to fix it
The reliable fix is central, not per-call-site. You will not win by editing 88 lines and hoping the next person remembers. Put the rule where every log passes through it.
1. Summarize HTTP errors into one safe line. Keep the status, the message and the other side's response body — which is what you actually need for debugging — and drop the request entirely.
2. Leave every other value alone, so plain errors keep their stack traces.
3. Install it once, in your logger, so no call site has to remember.
Here's the module we use. It has no dependencies and doesn't import axios, so it works with any client:
// safe-log.mjs: summarize HTTP-client errors for logs without printing your own credentials.
// Node 18+, no dependencies.
/** Keys whose presence means the error carries a copy of the request we sent. */
const REQUEST_CARRIERS = ["config", "options", "request", "isAxiosError"];
/** True for HTTP-client errors (axios, got, ky, …), whose serialized form embeds our request. */
export function carriesRequest(v) {
return Boolean(v) && typeof v === "object" && REQUEST_CARRIERS.some((k) => k in v);
}
function stringifyBody(data) {
if (data == null) return "";
if (typeof data === "string") return data;
try {
return JSON.stringify(data);
} catch {
return "[unserializable body]"; // e.g. circular response data
}
}
/**
* One safe line for an error: the response status, the message, and the other side's
* response body — never our request, headers, credentials or URL.
*/
export function safeHttpError(err, maxBody = 500) {
if (!carriesRequest(err)) {
if (err instanceof Error) return `${err.name}: ${err.message}`;
return String(err);
}
// Clients disagree on where the status lives: axios uses response.status, got uses
// response.statusCode, and a transport failure has no response at all — only a code.
const status = err.response?.status ?? err.response?.statusCode ?? err.status ?? err.code ?? "ERR";
const body = stringifyBody(err.response?.data ?? err.response?.body).slice(0, maxBody);
const message = typeof err.message === "string" ? err.message : "";
return `${status} ${message}${body ? ` — ${body}` : ""}`;
}
/** Pass values through untouched unless they carry our request, so stack traces survive. */
export function scrubForLog(value) {
return carriesRequest(value) ? safeHttpError(value) : value;
}
/** Wraps a logger's methods so every argument is scrubbed first. */
export function safeLogger(logger = console, methods = ["error", "warn", "info", "log", "debug"]) {
const wrapped = Object.create(logger);
for (const m of methods) {
if (typeof logger[m] !== "function") continue;
wrapped[m] = (...args) => logger[m](...args.map(scrubForLog));
}
return wrapped;
}
Wiring it in
Plain console, or anywhere you control the logger object:
import { safeLogger } from "./safe-log.mjs";
const log = safeLogger(console);
log.error("QBO token refresh failed", err); // prints: 401 Request failed… — {"error":"invalid_client"}
pino, as a serializer for the err key:
import pino from "pino";
import { carriesRequest, safeHttpError } from "./safe-log.mjs";
const log = pino({
serializers: { err: (e) => (carriesRequest(e) ? safeHttpError(e) : pino.stdSerializers.err(e)) },
});
log.error({ err }, "QBO token refresh failed");
winston, as a format that runs before any transport:
import winston from "winston";
import { carriesRequest, safeHttpError, scrubForLog } from "./safe-log.mjs";
const scrub = winston.format((info) => {
if (carriesRequest(info)) { // logger.error(err) — the error IS the info object
const summary = safeHttpError(info);
for (const k of Object.keys(info)) if (k !== "level") delete info[k];
info.message = summary;
return info;
}
for (const k of Object.keys(info)) info[k] = scrubForLog(info[k]); // errors passed as metadata
return info;
});
const logger = winston.createLogger({
format: winston.format.combine(scrub(), winston.format.json()),
transports: [new winston.transports.Console()],
});
NestJS, by subclassing the built-in logger and passing it to the app:
import { ConsoleLogger } from "@nestjs/common";
import { carriesRequest, safeHttpError } from "./safe-log.mjs";
export class SafeLogger extends ConsoleLogger {
private scrub = (v: unknown) => (carriesRequest(v) ? safeHttpError(v) : v);
override error(message: unknown, ...rest: unknown[]) {
super.error(this.scrub(message), ...rest.map(this.scrub));
}
override warn(message: unknown, ...rest: unknown[]) {
super.warn(this.scrub(message), ...rest.map(this.scrub));
}
}
// main.ts
const app = await NestFactory.create(AppModule, { logger: new SafeLogger() });
That's the shape we shipped. Every existing logger.error("…", err) call site became safe without
being edited, which is the point: a fix that needs no discipline is the only one that holds.
What each decision is for
- Only errors carrying a request are rewritten. Everything else, including plain
Errors, passes through untouched, so you keep stack traces where they matter. - The response body is kept, truncated. The vendor's
{"error":"invalid_client"}is the part that tells you what went wrong. Truncation stops one failing call from writing megabytes when an API returns an HTML error page. - The URL is dropped. Keys travel in query strings more often than anyone admits.
- The status comes from wherever the client puts it. axios uses
response.status, got usesresponse.statusCode, and a connection failure has no response at all, only a code such asECONNREFUSED. - Serialization failures are swallowed. A circular response body must not turn a log line into a second exception.
The part people skip: rotate the credentials
Fixing the code stops new leaks. It does nothing about the logs you've already written.
Assume the exposed credentials are in every place your logs have ever been: the container's stdout on each host, your log shipper, whatever storage and retention that ships to, any log-search product, and error-tracking tools that capture breadcrumbs. Copies age out on their own schedules, and some are in systems whose access list is wider than the secrets they now contain.
So, in this order:
- List what could have been printed. Every outbound integration whose calls are wrapped in a
logged
try: PSA and RMM tools, accounting and payments, security consoles, SMS and email providers, network vendor APIs. - Rotate each one, including OAuth refresh tokens, which are the worst case: a refresh token that still works is a standing grant, not a single session.
- Redeploy with the new values, then confirm each integration still works. Rotating a credential you can't verify is how a nightly sync quietly stops.
- Check for use you didn't make. Most vendor consoles show API access history.
- Shorten log retention where it's longer than you need, and keep it that way.
We rotated on this basis rather than because we'd found misuse. Rotation is cheap; assuming the logs were private is not.
How we tested it
The module ships with 36 tests that run against real axios errors, not mocks: a local server
returning 401 with a JSON body, an OAuth-style form post carrying a refresh token and client secret,
basic auth, a request timeout, a refused connection, and a 2,000-character error body.
Each test asserts both halves of the claim: that the default util.inspect output does contain the
marker token, so the bug is real and still reproducible, and that the summary doesn't, while still
carrying the status and the vendor's message. Others cover a got-shaped error object, circular response
data, plain errors passing through unchanged, and the wrapped logger scrubbing arguments in every
position.
We also checked the tests actually cover the safeguards: weakening any of 16 details in the module —
dropping the truncation, forgetting that got reports statusCode, scrubbing only the first argument —
makes the suite fail.
How to stop it coming back
A central scrubber holds only while nobody adds a second logging path. Two cheap guards:
A canary test in CI. The reproduction above becomes a test: make a real failing call through your own HTTP client with a marker credential, capture what your application's logger writes, and fail the build if the marker appears. It's the only check that tests the thing you actually care about, and it survives library upgrades that change how errors serialize.
// Fails the build if the logger ever prints the marker.
const printed = captureLogOutput(() => appLogger.error("integration failed", err));
assert.ok(!printed.includes("LEAK-CANARY-123"), "logger leaked the Authorization header");
A lint rule for new code. If your team uses ESLint, no-restricted-syntax can flag a raw error
passed to a logger and point people at the helper. Treat it as a signpost rather than the control:
the logger-level fix is what protects you, and the rule just shortens the conversation in review.
Frequently asked questions
Does this affect fetch?
Not in the same way. A failed HTTP status with fetch isn't an exception at all: you get a Response
object and check res.ok yourself. Network-level failures throw a TypeError that doesn't carry your
request. The risk returns if you build your own error class and attach the request to it for
debugging, which is a common wrapper pattern, or if you log the Request object.
Is this only a Node problem?
No. Any client that attaches the request to its exception has the same property, and most do, because
it's genuinely useful when debugging. Python's requests puts it on err.request, and .NET and Java
clients commonly expose the request message. The question to ask in any language is: does printing this
exception print my request?
Why not redact just the Authorization header?
Because it isn't the only carrier. Secrets also travel in the request body on token calls, in query strings, in cookies and in vendor-specific headers. An allow-list of what's safe to print beats a block-list of what isn't, and "the status, the message and the response body" is a short allow-list.
Doesn't dropping the request make debugging harder?
Slightly, and it's worth it. In practice the fields you reach for are the status, the vendor's error message, and which integration failed — and the last one belongs in your own log text, not in a dump of the request. If you need the URL, log the route name or a redacted path you construct yourself.
What about error-tracking services?
Check them specifically. If you capture exceptions to a tracker, the same object is being serialized there, often with more depth than your log line, and the result is retained by default. Most trackers support a hook to scrub an event before it's sent: use the same summarizer.
Related reading
If you're auditing where credentials can end up, the same review that found this also produced our SOC 2 user access review guide, and our Entra ID single sign-on guide covers the token checks that sit next to this in most applications. If you'd rather have someone run this kind of review with you, that's part of our managed security services.


