Blog / Tooling
Tooling · 7 min read · June 25, 2026

Your Laravel app might be emailing secrets right now

A stack of unsealed envelopes

Your Laravel app might be emailing customers their own API keys right now. Outgoing mail is the security blind spot almost nobody audits. A debug variable left in a Blade template, a misconfigured mailable, a stack trace forwarded to support, and suddenly a real secret or a customer's personal data is sitting in an inbox you do not control. Once it sends, it is gone. There is no recall, no patch, no second chance.

We spend most of an audit on what comes into an app: form input, query strings, uploads. The mail that goes out gets almost none of that scrutiny, even though it renders the same untrusted data through Blade and ships it straight past every firewall you own. So I built laravel-mail-guard: an open-source package that scans every outgoing message before it leaves your app, and refuses to send the dangerous ones.

Why outgoing email is a blind spot

A mailable is a template that runs with whatever you hand it. The failure modes are boringly common:

  • A password-reset or onboarding email that helpfully includes the raw token or API key "for your records".
  • A queued job that catches an exception and forwards the full stack trace, request payload and config to a support address.
  • A Blade partial reused from a logged-in view that still references $user fields you never meant to send.
  • A "contact form" notification that echoes the entire request body back to your team, credit-card field included.

None of these throw an error. They render, they queue, they send. And an inbox is the worst place for a secret to land: it is retained for years, often readable by support staff, frequently forwarded, and rarely encrypted at rest. You cannot rotate your way out of a key that is already in a thousand mailboxes.

What laravel-mail-guard checks

Every message is run through a set of rules before it is handed to the transport. The defaults that ship today:

  • secrets.private_key · critical: a private key (PEM block) that ended up in the message.
  • secrets.stripe_key · critical: a Stripe secret or live key in the body.
  • pii.credit_card · critical: a credit-card number sitting in plain text.
  • compliance.list_unsubscribe · warning: bulk mail with no List-Unsubscribe header.
  • privacy.tracking_pixel · warning: a remote tracking pixel quietly phoning home.

Critical findings are the ones that can stop a send; warnings are logged so you can triage them. The list is a starting point, not a ceiling: you add your own rules through one small contract, covered below.

Install it in about five minutes

# 1. install composer require laravelsecurityaudit/laravel-mail-guard # 2. create the table that stores the (redacted) message copies php artisan migrate # 3. optional: publish the config to tune rules and thresholds php artisan vendor:publish --tag=mail-guard-config

That is the whole setup. The guard hooks into Laravel's mail pipeline automatically, so every Mail::send() and queued mailable in your app is now scanned. It runs on Laravel 11, 12 and 13, PHP 8.2 and up.

Block in production, warn everywhere else

By default the guard is non-destructive: it scans, logs and stores a redacted copy, but it never stops a send. When you are ready to enforce, flip one environment variable so a critical finding hard-stops the message in production:

# .env, production MAIL_GUARD_BLOCK=true # default is false: warn and log, never block

While you are developing, point your browser at the local inbox to see exactly what your app is about to send, findings highlighted, before it goes anywhere:

# visit in your local environment /mail-guard

Catch leaks in CI before they merge

The runtime guard protects production. The scan command protects the pull request. It renders your mailables, runs the same rules, exits non-zero when a critical fires, and writes SARIF so the findings show up as code-scanning alerts right on the diff:

php artisan mail-guard:scan --min-severity=critical --format=sarif --output=mail-guard.sarif

Wiring it into GitHub Actions is two steps: run the scan, upload the SARIF.

# .github/workflows/mail-guard.yml - run: php artisan mail-guard:scan --min-severity=critical --format=sarif --output=mail-guard.sarif - uses: github/codeql-action/upload-sarif@v3 with: sarif_file: mail-guard.sarif

Now a mailable that starts leaking a key fails the build, and the reviewer sees the finding inline instead of discovering it in a customer's inbox three weeks later.

Assert it in your test suite

The package ships test helpers so the same rules become assertions. Drop them into a feature test for any mailable that touches sensitive data:

// after dispatching the mailable under test assertNoCriticalFindings(); assertNotFlagged('secrets.stripe_key');

The full set is assertNoFindings(), assertNoCriticalFindings(), assertFlagged($ruleId) and assertNotFlagged($ruleId). Use them to pin down a known-good template so a future refactor cannot quietly start leaking.

Write your own rules

The built-in rules cover the obvious secrets and PII. Your leaks are probably more specific: an internal hostname, a coupon code, a particular ID format. One contract covers it:

// app/MailGuard/NoInternalHostnames.php use LaravelSecurityAudit\MailGuard\Scanning\Contracts\Rule; class NoInternalHostnames implements Rule { // inspect the rendered message, return any findings }

Register it under scan.rules in config/mail-guard.php and it runs everywhere the defaults do: at send time, in the scan command, and in your tests. One rule, three enforcement points.

Why I built it

Most teams do not know this gap exists until a secret lands in the wrong inbox, and by then the only options are apology emails and key rotation. laravel-mail-guard is a five-minute install that closes the gap before that happens: Laravel 11, 12 and 13, PHP 8.2 and up, MIT licensed. The copies it stores are redacted, so you get an audit trail of what went out without keeping a second copy of the secret itself.

Source, issues and rule ideas are all welcome on GitHub: github.com/laravelsecurityaudit/laravel-mail-guard. If it catches one leak for you, it has paid for itself.

A scanner catches the patterns it knows about. If you want a senior engineer to read every mailable, queued job and exception handler the way an attacker would, that is what a security audit of your AI-generated app is for.

FAQ

Does scanning every email slow down sending?

The runtime check runs as the message is built and is cheap; mail is already an I/O-bound, usually queued operation, so the scan is noise next to the SMTP round trip. The heavier full render lives in the mail-guard:scan command, which you run in CI, not on the request.

Will it block legitimate email?

Not by default. Out of the box it only warns and logs. Blocking is opt-in through MAIL_GUARD_BLOCK=true, and only critical-severity rules stop a send. You can lower the severity of any rule, disable it, or add an allow-list rule in config/mail-guard.php.

Does storing copies of email create a new data risk?

The stored copies are redacted: anything a rule flags as a secret is masked before the record is written. You get an audit trail of what your app sent without persisting the secret a second time.

Which versions does it support?

Laravel 11, 12 and 13 on PHP 8.2 or newer, released under the MIT license.

Audit the code behind your emails.

A senior engineer reviews every line your assistant wrote, mailables included. Fixed price, every finding with a fix.