krishna@
9 min read#backend#tooling

Pino logging in production: redaction and the keys you forgot

Logging is the part of the stack everyone copy-pastes from a tutorial and never revisits. The default usually leaks secrets. Here is the redact config that doesn’t.

share
Dark workstation with terminal and code editor

the logger nobody thinks about

Logging is the part of the stack everyone copy-pastes from a tutorial and never revisits. The default console.log works in dev. The first production incident reveals it doesn't. The team scrambles, picks something — Winston, Bunyan, whatever StackOverflow recommended — and copy-pastes a basic config. That config survives, mostly unchanged, for the life of the service.

This is a write-up of why I use Pino, what the redaction config should look like, and the small set of conventions that make the difference between "logs that help during incidents" and "logs that are noise."

why Pino

Three reasons.

It's fast. Pino's pitch is structured JSON logging with low overhead. In a high-traffic service, the cost of logger.info({ user_id, request_id }, 'request') is small enough to leave in for every request. Less performant loggers (Winston, in particular, prior to v4) make you choose between log volume and request latency.

JSON by default. Every log line is a JSON object. This is what your log aggregator wants — Datadog, CloudWatch, Loki, whichever. Plain-text loggers force a parsing step at the aggregator that's lossy and slow. Pino skips it.

Pretty-print is a separate concern. In dev, you pipe Pino's output through pino-pretty for human-readable logs. In prod, you don't, and the logs go straight to the aggregator as JSON. The library doesn't try to be both at once.

the file

src/lib/logger.ts:

import pino from 'pino';

const isProd = process.env.NODE_ENV === 'production';

export const logger = pino({
  level: process.env.LOG_LEVEL ?? (isProd ? 'info' : 'debug'),
  redact: {
    paths: [
      'password',
      'token',
      'authorization',
      '*.password',
      '*.token',
      '*.authorization',
      'headers.authorization',
      'headers.cookie',
      'req.headers.authorization',
      'req.headers.cookie',
    ],
    censor: '[REDACTED]',
  },
  ...(isProd
    ? {}
    : {
        transport: {
          target: 'pino-pretty',
          options: { colorize: true, translateTime: 'HH:MM:ss.l' },
        },
      }),
});

About 30 lines. Most projects don't need more.

the redaction config is the part nobody gets right

Default Pino setups ship without redaction. This means:

  • Auth tokens in headers: logged.
  • Passwords in request bodies: logged.
  • API keys returned in error objects: logged.
  • Cookies: logged.

For a service that ever logs request objects (most do), this is a security incident waiting to happen. The first time a junior engineer logs req and it shows up in your aggregator with a session cookie attached, you'll wish you'd had redaction from day one.

The redact.paths config above covers the common cases:

  • password, token, authorization — bare keys at the top level. Catches logger.info({ password: '...' }).
  • *.password, *.token — single-level nested. Catches logger.info({ user: { password: '...' } }).
  • headers.authorization, headers.cookie — explicit nested paths. Catches request-object logging.
  • req.headers.authorization, req.headers.cookie — Express/Fastify nest the request under req. Catches logger.info({ req }, 'incoming').

The replacement is [REDACTED] — visible in logs so you know redaction happened, not silently dropped.

the keys you forgot

The list above isn't exhaustive. Here's the supplementary list I usually add depending on the service:

  • apiKey, api_key, api-key — common API key patterns.
  • secret, secrets — generic.
  • creditCard, credit_card, cardNumber — payment forms.
  • ssn, taxId — government IDs.
  • refresh_token, access_token — OAuth.
  • webhook_secret — Stripe and similar.

Add these as redact paths. The cost is negligible; the protection is large.

the conventions

A few logging conventions I enforce on every team:

Object first, message second. Pino's signature is logger.info(obj, msg), not logger.info(msg, obj). Get this backwards and the object becomes the message string and the msg becomes a context object. The output looks weird and search becomes hard.

// ❌ wrong
logger.info('user signed up', { user_id });

// ✅ right
logger.info({ user_id }, 'user signed up');

No console.*. Anywhere. The logger is the only way to write to stderr/stdout. Catch this with a Biome rule (noConsoleLog) — and add the logger as the only exception.

Use logger.child for context. When you have a request ID or a user ID that should appear on every log within a scope, create a child logger:

const log = logger.child({ request_id, user_id });
log.info({ action: 'list_orders' }, 'request received');
log.info({ result_count: 12 }, 'request completed');

The request_id and user_id get attached to every log line under the child, automatically. The aggregator can then group by them.

Levels matter. debug for "useful in dev, noisy in prod," info for "expected business event," warn for "something to investigate but not break," error for "this needs human attention." The default LOG_LEVEL in prod is info; debug should be off. If you find yourself wanting prod logs at debug to debug something, your info logs are too sparse.

the test

There's exactly one test worth writing for the logger: that redaction works.

import { describe, it, expect, vi } from 'vitest';
import { logger } from '@/lib/logger';

describe('logger redaction', () => {
  it('redacts authorization headers', () => {
    const out: any[] = [];
    const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
      out.push(chunk.toString());
      return true;
    });

    logger.info({ headers: { authorization: 'Bearer xxx' } }, 'request');

    spy.mockRestore();
    expect(out.join('')).toContain('[REDACTED]');
    expect(out.join('')).not.toContain('Bearer xxx');
  });
});

This is the test that catches "someone added a new common-secret key to a request shape and forgot to redact it." Worth one ugly test.

what about request logging?

The most common ask: "log every incoming request." The right answer in NestJS / Fastify / Express is to use the framework's built-in request logger, with redaction inherited from the Pino instance. Don't write your own:

// NestJS with @nestjs/platform-fastify
import { LoggerModule } from 'nestjs-pino';

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        logger,
        // skip /health to avoid log spam
        autoLogging: { ignore: (req) => req.url === '/health' },
      },
    }),
  ],
})

The integration handles request ID propagation, response time measurement, and structured logging of the request/response objects — all with your redaction config applied.

the operational story

When something goes wrong in production, the logs are how you find out what happened. The quality of the logs is the quality of the post-mortem.

Bad logs look like:

2026-04-15 14:32:01 INFO request received
2026-04-15 14:32:03 ERROR something went wrong

Good logs look like:

{"level":30,"time":1713178321000,"request_id":"abc-123","user_id":"u_456","action":"checkout","msg":"request received"}
{"level":50,"time":1713178323000,"request_id":"abc-123","user_id":"u_456","error":"PaymentDeclined","stripe_event":"evt_789","msg":"checkout failed"}

The good version has request_id (you can find every log for this request), user_id (you can find every request for this user), action (you know what they were doing), and the structured error (you can group by error type).

Pino + reasonable conventions gets you the second kind for free.

the meta-point

A logger is one of those pieces of infrastructure where the difference between "set up casually" and "set up well" is invisible right up until the moment it matters — and then it's everything.

Spend 30 minutes once. Get redaction right. Use logger.child for context. Forbid console.log in source. Then never think about it again.

That's the whole investment.


by Krishna Adhikari · Mar 2, 2026
share
// related.transmissions

Keep reading.