DEV Community

Gabriele Pieretti
Gabriele Pieretti

Posted on Originally published at gabrielepieretti.dev

Sensitive Data in Your Logs: What Laravel 13.27's Binding Masking Fixes (and What It Doesn't)

Originally published on gabrielepieretti.dev.

When a query fails, Laravel builds a QueryException whose message contains the SQL with every bound value interpolated into it. That's great while you're debugging. In production it means every failed insert copies customer data — an email, a name, maybe a national ID — into your logs, into the failed_jobs table, and into your APM traces. Laravel 13.27, released on August 26, finally ships a switch to turn that interpolation off. It's worth being precise about what it closes, and what it doesn't.

The problem: exceptions get written down

A QueryException message is designed to be read by a developer mid-debug: insert into users (email) values (?) tells you nothing about which row broke, the version with the value tells you immediately. The trouble is that the message doesn't stay on that developer's screen. It's a string attached to an exception, and exceptions get recorded everywhere:

  • in the log file or channel you configured in logging.php;
  • in the exception column of failed_jobs, because the failed-job provider casts the exception to a string and inserts it as-is;
  • in the spans of your APM or OpenTelemetry agent, if you run one;
  • in whatever error-tracking service your exception reporting ships data to.

Each of those places becomes an undeclared copy of the data you were writing to the database — with different retention, different access rules, and almost never accounted for when someone fills in a data-processing register. Across eleven years of building Laravel back-office systems, I've watched application logs get read by far more people than ever had database access. That's normal; logs exist to be read. The question is what ends up in them without anyone having decided it.

What Laravel 13.27 does

Version 13.27 introduces the per-connection option mask_bindings_in_exception_messages. The key ships in the framework's own config/database.php on all five default connections, so even an application that never published that file can enable it with a single environment variable:

DB_MASK_BINDINGS=true
Enter fullscreen mode Exit fullscreen mode

With the flag on, the exception message keeps its placeholders:

SQLSTATE[23000]: Integrity constraint violation: 1062
Duplicate entry 'ada@example.com' for key 'users_email_unique'
(SQL: insert into `users` (`email`, `name`, `national_id`)
 values (?, ?, ?))
Enter fullscreen mode Exit fullscreen mode

Notice the detail: the duplicate value still appears in the first line, because that part of the message comes from the database driver, not from Laravel. The flag masks the framework's interpolation — it doesn't rewrite what MySQL says. That's still a lot: the full insert with three columns of personal data is gone. But it's not a complete scrub of the message.

Why I'd turn it on almost everywhere

The cost of the flag is that when an exception fires in production you no longer see the values at a glance: you have to recover the bindings some other way — logging them separately to a channel with short retention and tight access, or reproducing the case in development. That's real friction. But the right question isn't "does this make debugging harder?", it's "who reads my logs, and how long do they stick around?". If the answer includes a third-party service, an APM, or months of retention, then binding interpolation is a category of personal-data processing you never declared to anyone — including yourself.

The threat model is bigger than one flag

This release matters to me because it touches a principle I built Miraviso around — it's my SaaS for hair salons: sensitive data has to be protected not just where you think you store it, but in every place your infrastructure re-copies it without asking. Logs, queues, traces, log backups, snapshots: every component that serializes state to help you debug is a potential shadow archive.

In Miraviso, the notes a salon keeps about its clients can contain allergies and scalp conditions, and there I took the conclusion all the way: those notes are encrypted with a key derived on the device, and the server stores envelopes it cannot open. I wrote about that design in a dedicated post. The property that's relevant here is that the envelope design makes the logging problem structurally impossible for those fields: if the plaintext never exists server-side, no exception, however verbose, can interpolate it into a message. There's no flag to remember to enable, no misconfigured APM agent that can betray you.

I'm not arguing every field deserves that treatment — the same segmentation I apply there applies here. Most data can live in plaintext on the server, and for that data Laravel 13.27's flag is the right move: cheap, immediate, reversible. A small set of fields is delicate enough that leaking them should be impossible rather than unlikely. The test for telling them apart never changes: does the server have a legitimate need to read this value? If not, make sure it can't.

What to do today

If you run a Laravel application in production that handles personal data, the list is short. Upgrade to 13.27 and set DB_MASK_BINDINGS at least in production. Then open failed_jobs and look at the exception column of your old records: the flag protects the future, it doesn't clean up the past, and those rows have whatever retention you gave them — often forever. Finally, ask which other components of your stack cast exceptions to strings and keep them around. It's a less entertaining exercise than writing nonogram solvers, but it makes your next audit a great deal shorter.

Top comments (0)