Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Stop Logging the Password

  • security
  • laravel
  • php
  • typescript
  • logging

Nobody writes Log::info($password) on purpose.

They write Log::debug('signup', $request->all()) to chase a bug on a Tuesday. The bug gets fixed. The log line stays. Six months later every new user's password is sitting in plain text in a log file, a log aggregator, and whatever backup holds both.

Logs feel internal, so people stop treating them like data. They are data. Usually data with weaker access control than your database.

The usual ways it gets in

It's almost never one dramatic mistake. It's a handful of normal ones:

  • Dumping the whole request "just for now."
  • Logging an HTTP client error, which carries the request headers, including Authorization.
  • Stack traces that print function arguments, including the one named $password.
  • Tokens in query strings, which land in every access log between the browser and your app.
  • An error tracker sending request bodies and cookies along with the exception.

Each of these is reasonable in the moment. Together they mean your logs know more than your support team is allowed to.

Log what you need, not what you have

The fix that does the most is boring: stop passing whole objects to the logger.

// Logs everything the client sent, forever.
Log::debug('signup', $request->all());
 
// Logs what you actually needed to debug.
Log::info('signup.started', $request->only(['email', 'plan']));

except() exists too, but it's a denylist. The day someone adds a ssn field to the form, except(['password']) happily logs it. only() fails safe. New fields stay out until you decide they belong.

Same with models. Eloquent's $hidden keeps attributes out of toArray() and toJson(), and the default User model already hides password and remember_token:

protected $hidden = ['password', 'remember_token', 'two_factor_secret'];

If you add a sensitive column, add it here. Then Log::info('user', $user->toArray()) doesn't betray you. (Still, logging the id is usually enough.)

Keep secrets out of stack traces

When an exception is thrown, PHP's trace includes the arguments each function was called with. That's great until the function is login(string $email, string $password).

PHP 8.2 added an attribute for exactly this:

public function attempt(
    string $email,
    #[\SensitiveParameter] string $password,
): bool {
    // ...
}

In the trace, that argument shows up as a SensitiveParameterValue object instead of the actual string. One attribute, and the most common leak in an error report is gone. Put it on anything that takes a password, API key, or token.

Hidden context is still context

Laravel 11's Context facade is handy for tagging every log line in a request. It also has a hidden side, for values you need during the request but never want written out:

use Illuminate\Support\Facades\Context;
 
Context::add('tenant_id', $tenant->id);       // shows up in logs
Context::addHidden('api_token', $token);      // available in code, not in logs

Useful when a job or listener needs the value later and you don't want to thread it through five method signatures.

Error trackers send more than the error

Sentry and friends are built to give you as much context as possible. That's the job. It also means request data can ride along with every exception.

In the Laravel SDK, send_default_pii in config/sentry.php controls whether things like the user's IP and request data get attached. Leave it off unless you've decided you need it.

On the JavaScript side, beforeSend is your last chance to clean up an event before it leaves:

import * as Sentry from "@sentry/node";
 
Sentry.init({
  dsn: process.env.SENTRY_DSN,
  beforeSend(event) {
    if (event.request?.headers) {
      delete event.request.headers["authorization"];
      delete event.request.headers["cookie"];
    }
    return event;
  },
});

Check your tracker's own scrubbing settings too. Just don't assume they caught everything without looking at a real event.

TypeScript: redact in the logger, not at every call site

If you use pino in Node, redaction is built in. You give it paths, and it censors them on every log line no matter who wrote the call:

import pino from "pino";
 
export const logger = pino({
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "*.password",
      "*.token",
      "*.cardNumber",
    ],
    censor: "[redacted]",
  },
});

That's the right layer for it. You will forget to scrub one call site. The logger config won't forget.

And watch HTTP client errors. An axios error carries the request config, headers and all. Logging the whole thing logs your bearer token:

try {
  await api.post("/charges", payload);
} catch (err) {
  if (axios.isAxiosError(err)) {
    logger.error(
      {
        status: err.response?.status,
        url: err.config?.url,
        code: err.code,
      },
      "charge request failed"
    );
  }
  throw err;
}

Status, url, and error code are what you need to debug it. The token isn't.

Don't put secrets in URLs

Query strings get logged by everything: the browser history, the load balancer, nginx, your APM. A password reset link with a token is one thing, since it's short-lived and single-use. An API key in ?api_key= is in a dozen access logs by lunch.

Send credentials in headers. If a vendor only supports query string auth, at least keep that URL out of your own application logs.

WordPress: debug.log is not a private diary

Turning on WP_DEBUG_LOG writes to wp-content/debug.log by default. That folder is served by your web server, so on a lot of setups that file is one URL away from anyone who guesses it.

Point it somewhere outside the web root instead:

define('WP_DEBUG', true);
define('WP_DEBUG_LOG', '/var/log/wordpress/debug.log');
define('WP_DEBUG_DISPLAY', false);

And turn it back off when you're done. Debug mode on production is how plugin notices full of customer data end up on disk.

Go look

All of this is prevention. The other half is checking what's already there.

Search your logs and your error tracker for the obvious stuff: password, Bearer , sk_live_, card, ssn. If something turns up, fixing the code isn't the whole job. That data is still in old log files, in the aggregator's retention window, and in backups. Rotate any credential that showed up, and find out how long those logs stick around.

The habit

Before you add a log line, ask one question: if this file got pasted into a public Slack channel, would anyone have to send an apology email?

If yes, log the id and the outcome. Not the payload.

← All posts