Secrets in Your Logs: Redacting Tokens, Passwords and PII Before They Leave the App

Application logs routinely capture passwords, bearer tokens and personal data. Build a tested redaction layer in JavaScript, with Python and Go equivalents, and stop log forging at the source.

Logs are the quiet place where secrets end up. A debug line that dumps a request object, an exception that prints a connection string, a retry message that echoes an Authorization header: each one copies a credential into a system with weaker access control than your database. We chose this as an evergreen topic because no existing article here covers log content hygiene, and no fresh news item from the last 48 hours met our bar for developer relevance.

What the guidance says

The OWASP Logging Cheat Sheet (opened) has a “Data to exclude” section. It lists authentication passwords, access tokens, encryption keys and other primary secrets, database connection strings, payment card data, sensitive personal data, session identification values and application source code. Such data should usually not be recorded directly and should instead be removed, masked, sanitized, hashed or encrypted. For session IDs it suggests a hashed value if you still need to correlate events. It also names file paths, internal network addresses and names, phone numbers and email addresses as data needing special handling, such as pseudonymizing identifiers when identity is not needed.

Everything below the next heading is our assessment of how to apply that guidance, not a claim from the sheet.

Where leaks come from

  • Logging whole objects: log.info(req), console.log(req.headers), or serializing a user model with its password hash.
  • Exception messages that embed DSNs, URLs with credentials, or SQL with bound values.
  • HTTP client debug logging, which often prints request headers verbatim.
  • User-controlled strings containing newlines, which let an attacker forge extra log lines. Our CRLF injection and log poisoning guide covers that attack in depth.

A redaction layer you can test

Key-based redaction handles structured data. Pattern-based redaction catches secrets that appear inside free text. Doing both, plus newline stripping, in one function gives you a single choke point. This JavaScript was run locally on Node.js v22:

const SENSITIVE = new Set(['password', 'token', 'authorization', 'cookie', 'secret', 'apikey', 'api_key', 'set-cookie']);

export function redact(value, depth = 0) {
  if (depth > 6) return '[TRUNCATED]';
  if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
  if (value && typeof value === 'object') {
    const out = {};
    for (const [k, v] of Object.entries(value)) {
      out[k] = SENSITIVE.has(k.toLowerCase()) ? '[REDACTED]' : redact(v, depth + 1);
    }
    return out;
  }
  if (typeof value === 'string') {
    return value
      .replace(/\r|\n/g, ' ')
      .replace(/(Bearer\s+)[A-Za-z0-9._~+\/-]+=*/gi, '$1[REDACTED]');
  }
  return value;
}

Given a request object with an Authorization header, a password field, a nested apiKey, a username containing \nINFO fake entry, and a note containing Bearer xyz123, the output was:

{"user":"alice INFO fake entry","headers":{"Authorization":"[REDACTED]","Accept":"application/json"},"body":{"email":"a@b.c","password":"[REDACTED]","nested":[{"apiKey":"[REDACTED]"}]},"note":"retry with Bearer [REDACTED] failed"}

The function builds a new object, so the original request still holds the real password. That matters: redaction must never mutate data the application still needs.

Note what this does not do. The email field passed through untouched, because our deny list is only about credentials. Decide deliberately whether email addresses belong in your logs, using the pseudonymization guidance above.

Python and Go equivalents

Python, untested (no Python runtime was available here). A logging.Filter applies redaction to every record, including third-party libraries:

import logging, re

BEARER = re.compile(r"(Bearer\s+)[A-Za-z0-9._~+/-]+=*", re.I)

class RedactFilter(logging.Filter):
    def filter(self, record):
        msg = record.getMessage().replace("\r", " ").replace("\n", " ")
        record.msg = BEARER.sub(r"\1[REDACTED]", msg)
        record.args = ()
        return True

logging.getLogger().addHandler(logging.StreamHandler())
for h in logging.getLogger().handlers:
    h.addFilter(RedactFilter())

Go, untested (no Go toolchain was available here). The standard log/slog package lets you rewrite attributes through ReplaceAttr; verify the signature against your Go version:

var sensitive = map[string]bool{"password": true, "token": true, "authorization": true}

opts := &slog.HandlerOptions{
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        if sensitive[strings.ToLower(a.Key)] {
            return slog.String(a.Key, "[REDACTED]")
        }
        return a
    },
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))

Limits of redaction

A deny list only catches names you thought of. Our assessment is that it is a safety net, not a design. Better controls come first:

  1. Log specific fields you chose, never whole request, response or user objects.
  2. Wrap secrets in a type whose string form is masked, so accidental interpolation is harmless.
  3. Turn off HTTP client debug logging in production.
  4. Restrict log access and set retention, since redaction will eventually miss something.
  5. Add a CI test that logs a known canary secret and fails if it appears in captured output.
  6. If a secret does reach logs, rotate it. Scrubbing the log file does not undo copies already shipped to a collector.

Sources