Security 11 min read Markdown

Security Logging and Monitoring Failures in Laravel (OWASP A09)

Most Laravel apps log application errors but record almost nothing about security events. Here is how to add a dedicated security channel, capture auth events, redact secrets, and alert on attacks before they become breaches.

Matt King
Matt King
July 9, 2026
Last updated: July 9, 2026
Security Logging and Monitoring Failures in Laravel (OWASP A09)

Your Laravel app has authentication. Failed logins return a 401. Passwords are hashed with bcrypt. You might think you would notice if someone broke in, but ask yourself a harder question: if an attacker had valid credentials right now, would anything in your system tell you?

For most Laravel applications the honest answer is no. The default laravel.log captures unhandled exceptions and whatever you remembered to Log::info(), and nothing else. That gap is what OWASP calls A09:2021 — Security Logging and Monitoring Failures — and it is the reason breaches so often go undiscovered for weeks or months. You cannot investigate, and you certainly cannot stop, an attack you never saw.


Why this category exists

Logging failures rarely cause a breach on their own. They turn a small incident into a catastrophic one. An attacker who guesses a weak admin password might only have a few hours of useful access — unless nobody is watching, in which case those hours become months of quiet data exfiltration.

The pattern shows up again and again in post-incident reports: detection is the slow link. When an organisation has no record of who logged in, from where, and what they touched, the dwell time between compromise and discovery stretches out. By the time someone notices, the logs that could have explained what happened either never existed or have already rotated away.

Detection and response depend entirely on having a trustworthy record. That record is not free — you have to decide what to capture, write it deliberately, send it somewhere safe, and look at it. Laravel gives you good building blocks for all four steps.


What you should log as security events

Application logging and security logging are different jobs. A 500 error belongs in your error tracker. A failed login belongs in a security log that you can audit later. The events worth treating as security signals are predictable:

  • Authentication outcomes — successful logins, failed logins, and logouts. The failures matter most; a spike in them is the clearest early warning of a brute-force or credential-stuffing attack.
  • Credential lifecycle changes — password changes, password resets, and email-address changes. Account takeover almost always involves one of these.
  • Two-factor changes — enrolment, disabling, and recovery-code regeneration. Attackers disable 2FA the moment they get in.
  • Authorisation failures — every 403. A legitimate user rarely trips authorisation checks; a sudden run of them usually means someone is probing for endpoints they should not reach.
  • Input validation failures on sensitive endpoints — repeated validation errors on payment, admin, or API routes can signal fuzzing or an exploit attempt.
  • Privilege and role changes — any time a user gains a role, a permission, or admin status.
  • Administrative actions — deleting users, exporting data, changing billing, impersonating accounts.

Every one of these entries needs four facts to be useful: who (the authenticated user id, or guest plus the supplied email), what (the action), when (a timestamp), and where (the client IP and the route). Without the where, you cannot tell an attacker from a forgetful customer.


What you must never log

The flip side is just as important. Logs leak, get backed up, get shipped to vendors, and get read by support staff. Anything sensitive that lands in a log is now a second copy of your most dangerous data, sitting in a place with weaker controls than your database.

Never write any of these to a log, ever:

  • Passwords — plaintext or otherwise.
  • API tokens and Bearer tokens — including the Authorization header.
  • Session identifiers — a logged session id is a logged way to impersonate the user.
  • Full card numbers (PANs) and CVVs — this is a PCI-DSS violation as well as a bad idea.
  • Personal data beyond what an incident actually requires — log the user id, not the full profile.

The trouble is that most leaks are accidental. Three patterns account for the majority of them in Laravel codebases:

// The classic mistake — dumps every field, including 'password'
Log::info('Login attempt', $request->all());
// Unhandled exceptions can serialise the request body into the stack trace
// context, including the credentials that were submitted.
throw new \RuntimeException('Payment failed for ' . json_encode($request->all()));

The third is Telescope. Laravel Telescope records requests, query bindings, mail, and jobs, which means it happily stores passwords and tokens in your primary database. It is a brilliant local debugging tool and a serious liability in production. If you run it on a live site, lock it down hard — we cover exactly how in our guide to running Telescope safely in production.


Building a dedicated security channel

Start by giving security events their own home. Mixing them into laravel.log makes them impossible to find and impossible to forward separately. Add a channel in config/logging.php:

// config/logging.php
'channels' => [
    // ...existing channels...

    'security' => [
        'driver' => 'daily',
        'path' => storage_path('logs/security.log'),
        'level' => 'info',
        'days' => 90,
        'replace_placeholders' => true,
    ],
],

A 90-day retention is a sensible floor — investigations often start long after the event. Now write to it with structured context rather than string interpolation. Context arrays keep your data queryable once it reaches a log platform:

Log::channel('security')->info('auth.login.failed', [
    'email' => $email,
    'ip' => request()->ip(),
    'route' => request()->route()?->getName(),
    'user_agent' => request()->userAgent(),
]);

Notice the message is a stable event name, auth.login.failed, not a sentence. That lets you filter and count by event type later, which is the whole point of structured logging.


Subscribing to Laravel's auth events

You do not need to scatter Log::channel('security') calls through every controller. Laravel already fires events for the authentication lifecycle, so the cleanest approach is one listener that subscribes to all of them and writes to the security channel.

The events you care about live in the Illuminate\Auth\Events namespace: Login, Failed, Logout, Lockout, and PasswordReset. Create a single subscriber:

// app/Listeners/SecurityEventSubscriber.php
namespace App\Listeners;

use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Log;

class SecurityEventSubscriber
{
    public function handleLogin(Login $event): void
    {
        $this->log('auth.login.success', [
            'user_id' => $event->user->getAuthIdentifier(),
        ]);
    }

    public function handleFailed(Failed $event): void
    {
        $this->log('auth.login.failed', [
            'email' => $event->credentials['email'] ?? null,
        ]);
    }

    public function handleLogout(Logout $event): void
    {
        $this->log('auth.logout', [
            'user_id' => $event->user?->getAuthIdentifier(),
        ]);
    }

    public function handleLockout(Lockout $event): void
    {
        $this->log('auth.lockout', [
            'email' => $event->request->input('email'),
        ]);
    }

    public function handlePasswordReset(PasswordReset $event): void
    {
        $this->log('auth.password.reset', [
            'user_id' => $event->user->getAuthIdentifier(),
        ]);
    }

    private function log(string $event, array $context): void
    {
        Log::channel('security')->info($event, array_merge($context, [
            'ip' => request()->ip(),
            'route' => request()->route()?->getName(),
            'user_agent' => request()->userAgent(),
            'at' => now()->toIso8601String(),
        ]));
    }

    public function subscribe($events): array
    {
        return [
            Login::class => 'handleLogin',
            Failed::class => 'handleFailed',
            Logout::class => 'handleLogout',
            Lockout::class => 'handleLockout',
            PasswordReset::class => 'handlePasswordReset',
        ];
    }
}

In Laravel 11 and 12, event subscribers are discovered automatically when you call Event::subscribe() in a service provider, or you can register the class directly:

// app/Providers/AppServiceProvider.php
use App\Listeners\SecurityEventSubscriber;
use Illuminate\Support\Facades\Event;

public function boot(): void
{
    Event::subscribe(SecurityEventSubscriber::class);
}

That single class now records every login, failure, logout, lockout, and password reset across the whole application, with full who/what/when/where context, and it never touched a controller.

For authorisation failures, hook the same channel into your policies or the Gate::after callback. If you want a refresher on where those decisions are made, our walkthrough of gates and policies shows the structure. Logging a 403 is a few lines once you know where the deny happens.


Redacting sensitive keys

Even with a tidy channel, context arrays can pick up data they should not. Build a small redactor and run every context array through it before logging — belt and braces against the $request->all() habit creeping back in.

// app/Support/LogRedactor.php
namespace App\Support;

class LogRedactor
{
    private const SENSITIVE = [
        'password', 'password_confirmation', 'token',
        'api_token', 'secret', 'authorization',
        'card_number', 'cvv', 'cvc',
    ];

    public static function redact(array $context): array
    {
        foreach ($context as $key => $value) {
            if (in_array(strtolower((string) $key), self::SENSITIVE, true)) {
                $context[$key] = '[REDACTED]';
            } elseif (is_array($value)) {
                $context[$key] = self::redact($value);
            }
        }

        return $context;
    }
}

You can also register this globally with a Monolog processor so every channel benefits, not just the security one. That catches the case where a developer logs a sensitive payload to the default channel by mistake.


Centralisation and alerting

Logs that live only on the application server are nearly worthless for incident response. An attacker who gets a shell can read them, edit them, or delete them, and your daily rotation may erase the evidence before you ever look. Ship them off the box.

Send the security channel to a remote sink — Sentry, CloudWatch Logs, or a dedicated SIEM — in addition to writing the local file. A common setup is a stack channel that fans out:

// config/logging.php
'security' => [
    'driver' => 'stack',
    'channels' => ['security_file', 'sentry'],
    'ignore_exceptions' => false,
],

Two properties matter once logs are off the server. They should be tamper-resistant — append-only storage or a destination with separate credentials, so compromising the app does not mean compromising the audit trail. And they should be monitored, because a log nobody reads catches nothing.

Alerting is where logging turns into detection. The signals are mostly about rate, not individual events:

Signal Threshold idea What it suggests
auth.login.failed spike 50+ failures on one account in 1 min Brute-force on a known user
auth.login.failed spread Failures across 200+ accounts from one IP Credential stuffing
auth.lockout surge Lockouts climbing across many accounts Automated attack in progress
403 authorisation failures 20+ from one user or IP in 5 min Privilege-escalation probing
auth.password.reset cluster Many resets in a short window Account-takeover attempt

Most log platforms can express these as saved searches with alert thresholds. Pair the alerting with throttling at the edge — our piece on rate limiting with Sanctum covers stopping the volume before it ever reaches your business logic, which complements the detection side nicely.


A quick reference: log it, never log it, redact it

When you are unsure how to treat a field, this table is the short version:

Data / event Decision
Login success / failure Log it (with user id or email + IP)
Logout Log it
Password change / reset Log the event, not the password
2FA enrol / disable Log it
Authorisation failure (403) Log it (user, route, IP)
Admin / privilege change Log it (who changed what)
Password / password_confirmation Never log it
API token / Bearer / Authorization header Never log it
Session id Never log it
Full card number / CVV Never log it
Request body on sensitive routes Redact known sensitive keys
Email address in a security event Log it (it is the who)

The general rule: log the fact that something happened and enough identity to investigate it, never the secret that made it work.

For the wider context of how this category sits alongside the rest of the OWASP list, see our overview of the OWASP Top 10 for Laravel.


Where to start this week

You do not need a SIEM and a security team to close the worst of this gap. One concrete step moves you from blind to watchful: add a dedicated security channel to config/logging.php and wire up an auth-event subscriber like the one above. That alone gives you a queryable record of every login, failure, and lockout — the data you will desperately want the day something goes wrong.

Then audit your codebase for the leak vectors: search for $request->all() near any Log:: call, and check whether Telescope is enabled in production. Want to know what you are exposing before you start? Run a free StackShield scan and it will flag logging gaps, secret leaks, and the other OWASP issues hiding in your Laravel app.

Free security check

Is your Laravel app exposed right now?

34% of Laravel apps we scan have at least one critical issue. Most teams don't find out until something breaks. Our free scan checks your live application in under 60 seconds.

18% have debug mode on
72% missing security headers
12% have exposed .env
Scan My App Free No signup required. Results in 60 seconds.

Frequently Asked Questions

What security events should a Laravel app log?

Log authentication successes and failures, logout, password changes and resets, two-factor enrolment changes, authorisation failures (403s), input validation failures on sensitive endpoints, role and privilege changes, and administrative actions. Each entry should capture who (the user or guest id), what (the action), when (a timestamp), and where (the IP address and route). The goal is a trail that lets you reconstruct an incident after the fact, not a firehose of every request. Subscribing to Laravel's built-in auth events gives you most of the authentication coverage with very little code.

Is it safe to log the full request with Log::info($request->all())?

No. Calling Log::info($request->all()) on a login, registration, or payment endpoint will write plaintext passwords, tokens, and card details straight to disk. Those log files are frequently shipped to third-party services, backed up, and read by people who should never see credentials. Either log a hand-picked allowlist of safe fields, or redact known sensitive keys before writing anything. Treat your logs as a system that can be breached just like your database.

Why is running Telescope in production a logging risk?

Telescope records requests, queries, mail, jobs, and cache operations, which means it captures request payloads and bindings that often contain passwords and tokens. In production that data sits in your primary database, grows quickly, and is exposed to anyone who reaches the Telescope dashboard. If you do run it in production, gate it behind a strict authorisation gate, prune aggressively, and filter sensitive entries. See our guide on running Telescope safely in production for the full configuration.

Where should Laravel security logs be sent?

Ship them off the server to a destination an attacker cannot easily reach or rewrite, such as Sentry, CloudWatch, or a dedicated SIEM. Logs that live only on the application server can be deleted or edited by anyone who compromises the box, which destroys their value during an investigation. Centralising also lets you correlate events across multiple servers and set up alerting. A daily local file plus a remote sink is a reasonable starting point.

How do I detect a brute-force attack from my logs?

Alert on the rate of failed authentication events rather than individual failures. A handful of failed logins is normal; fifty failures against one account in a minute, or failures spread across hundreds of accounts from a single IP, is an attack. If you write each Failed event to a structured security channel with the IP and email, your log platform can count those events over a time window and fire an alert when a threshold is crossed. The same pattern works for sudden spikes in 403 authorisation failures.

Stay Updated on Laravel Security

Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.