Security 10 min read

Securing Laravel Horizon in Production: A Complete Guide

Laravel Horizon exposes your entire queue system, including job payloads, failed jobs with user data, and worker status. Here is how to lock it down properly in production.

Matt King
Matt King
May 2, 2026
Last updated: May 2, 2026
Securing Laravel Horizon in Production: A Complete Guide

Laravel Horizon provides a dashboard and code-driven configuration for your Redis queues. It is an essential tool for managing queue workers in production. It is also one of the most commonly exposed Laravel endpoints, and the data it leaks is far more sensitive than most developers realize.

If you have secured your Telescope dashboard (or read our guide on Telescope security in production), the approach for Horizon is similar but the risks are different. Telescope is a debugging tool you might choose to disable entirely. Horizon is a queue supervisor you need running in production. You cannot just remove it; you have to gate it properly.


What Horizon Exposes

An ungated Horizon dashboard gives an attacker visibility into your entire queue system. Here is what is accessible:

Job payloads

Every job that passes through your queues is visible in Horizon, including the serialized payload data. Depending on your application, this may include:

  • User email addresses and names
  • Password reset tokens
  • Payment processing data
  • Notification content
  • API credentials passed to jobs
  • File paths and internal URLs
// This job's payload is fully visible in Horizon
class SendWelcomeEmail implements ShouldQueue
{
    public function __construct(
        public User $user,    // User model is serialized, including email, name
        public string $token  // Verification token visible in plain text
    ) {}
}

Failed jobs

Failed jobs are even worse. Horizon shows the full exception trace, the input data that caused the failure, and the number of retry attempts. A failed ProcessPayment job might expose:

  • Customer billing details
  • Payment gateway API keys (if passed as job parameters)
  • Internal error messages revealing database structure
  • Stack traces showing file paths and application architecture

Worker and infrastructure details

The Horizon dashboard also reveals:

  • Number and configuration of queue workers
  • Which queues exist and their throughput
  • Redis connection status
  • Server memory and CPU usage per worker
  • Supervisor configuration

This is valuable reconnaissance data for an attacker mapping your infrastructure.


How to Gate Horizon Properly

Step 1: Configure the authorization gate

Horizon uses a gate method in HorizonServiceProvider to control dashboard access. After a fresh Horizon installation, this gate often defaults to allowing all access in local environments, and many developers forget to configure it for production.

Open app/Providers/HorizonServiceProvider.php:

<?php

namespace App\Providers;

use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;

class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
    public function boot(): void
    {
        parent::boot();

        // Horizon night mode (optional)
        // Horizon::night();
    }

    protected function gate(): void
    {
        Gate::define('viewHorizon', function (User $user) {
            return in_array($user->email, [
                'admin@yourcompany.com',
                'devops@yourcompany.com',
            ]);
        });
    }
}

This gate requires the user to be authenticated and checks their email against an allowlist. Unauthenticated visitors will receive a 403 response.

Common mistake: Using a role check like $user->isAdmin() without verifying the gate is registered at all. If the gate() method is empty or missing, Horizon defaults to open access in non-local environments.

Step 2: Verify the gate works

After configuring the gate, verify it is working correctly:

# Should return 403 or redirect to login (not 200)
curl -s -o /dev/null -w "%{http_code}" https://yourapp.com/horizon

# Test with an unauthenticated session
curl -s -o /dev/null -w "%{http_code}" https://yourapp.com/horizon/api/stats
curl -s -o /dev/null -w "%{http_code}" https://yourapp.com/horizon/api/workload
curl -s -o /dev/null -w "%{http_code}" https://yourapp.com/horizon/api/masters

Check the API endpoints too, not just the dashboard. Horizon's frontend makes API calls to /horizon/api/* endpoints, and these must also be gated.

Step 3: Block at the web server level

Defense in depth means not relying solely on Laravel's application-level gate. Add a web server block as a second layer:

Nginx:

location /horizon {
    # Allow your office IP
    allow 203.0.113.50;
    # Allow your VPN
    allow 10.8.0.0/24;
    # Block everyone else
    deny all;

    # Pass to PHP-FPM as normal
    try_files $uri $uri/ /index.php?$query_string;
}

Apache:

<Location /horizon>
    Require ip 203.0.113.50
    Require ip 10.8.0.0/24
</Location>

This ensures that even if a bug in Laravel's gate logic allows access, the web server blocks the request before it reaches PHP.

Step 4: Use environment-based configuration

Add an additional environment check in your gate for extra safety:

protected function gate(): void
{
    Gate::define('viewHorizon', function (User $user) {
        // Never allow in production unless explicitly enabled
        if (app()->environment('production') && !config('horizon.allow_dashboard')) {
            return false;
        }

        return in_array($user->email, config('horizon.authorized_emails', []));
    });
}

Then in your .env:

HORIZON_ALLOW_DASHBOARD=true

And in config/horizon.php:

'allow_dashboard' => env('HORIZON_ALLOW_DASHBOARD', false),
'authorized_emails' => explode(',', env('HORIZON_AUTHORIZED_EMAILS', '')),

This pattern keeps the authorized email list out of your code and makes it easy to disable the dashboard entirely by removing the environment variable.


Common Mistakes That Leave Horizon Exposed

1. Forgetting to configure the gate after installation

When you run php artisan horizon:install, the generated HorizonServiceProvider includes a gate method, but it only allows access in the local environment by default:

// Default gate after fresh install
protected function gate(): void
{
    Gate::define('viewHorizon', function ($user) {
        return in_array($user->email, [
            //
        ]);
    });
}

The empty email array means no authenticated user can access Horizon. However, if you remove the gate entirely or modify it incorrectly during development, Horizon becomes open.

2. Not registering HorizonServiceProvider

If HorizonServiceProvider is not registered in your bootstrap/providers.php (Laravel 11+) or config/app.php (Laravel 10 and earlier), the gate never gets defined and Horizon may fall back to its default behavior.

Verify it is registered:

// bootstrap/providers.php (Laravel 11+)
return [
    App\Providers\AppServiceProvider::class,
    App\Providers\HorizonServiceProvider::class,  // Must be present
];

3. Gating the dashboard but not the API routes

Horizon's dashboard is a Vue.js SPA that calls API endpoints under /horizon/api/. Some developers add middleware to the /horizon route but forget that the API endpoints also need protection. The gate method handles both, but custom middleware configurations can miss the API routes.

Test the API endpoints directly:

curl -s https://yourapp.com/horizon/api/stats
curl -s https://yourapp.com/horizon/api/recent-jobs
curl -s https://yourapp.com/horizon/api/failed

4. Using different authentication on Horizon routes

If you have multiple authentication guards (e.g., separate admin and user guards), make sure Horizon's gate uses the correct guard:

protected function gate(): void
{
    Gate::define('viewHorizon', function ($user) {
        // If you use a custom admin guard, ensure the right guard is checked
        if (!auth()->guard('admin')->check()) {
            return false;
        }

        return auth()->guard('admin')->user()->hasPermission('view-horizon');
    });
}

Securing Job Payload Data

Even with a properly gated dashboard, consider what data your jobs carry. Sensitive data in job payloads is stored in Redis and visible to anyone with Redis access.

Minimize payload data

Pass IDs instead of full models when possible:

// Bad: Full user model serialized in job payload
class ProcessOrder implements ShouldQueue
{
    public function __construct(
        public User $user,
        public string $creditCardNumber
    ) {}
}

// Good: Only the ID, fetch data when the job runs
class ProcessOrder implements ShouldQueue
{
    public function __construct(
        public int $userId,
        public int $orderId
    ) {}

    public function handle(): void
    {
        $user = User::findOrFail($this->userId);
        $order = Order::findOrFail($this->orderId);
        // Process with fresh data
    }
}

Encrypt sensitive job data

If you must pass sensitive data in job payloads, encrypt it:

class SendPasswordReset implements ShouldQueue
{
    public string $encryptedToken;

    public function __construct(string $token)
    {
        $this->encryptedToken = encrypt($token);
    }

    public function handle(): void
    {
        $token = decrypt($this->encryptedToken);
        // Use the token
    }
}

Clean up failed jobs

Failed jobs persist in Redis and in the failed_jobs database table. Set up automatic cleanup:

// In config/horizon.php
'trim' => [
    'recent' => 60,        // Minutes to keep recent jobs
    'pending' => 60,       // Minutes to keep pending jobs
    'completed' => 60,     // Minutes to keep completed jobs
    'recent_failed' => 10080, // 7 days for failed jobs
    'failed' => 10080,
    'monitored' => 10080,
],

Also schedule the failed jobs table cleanup:

// In routes/console.php or your schedule
Schedule::command('queue:flush')->weekly();

Monitoring for Exposed Horizon Dashboards

Manual verification works, but configuration can drift after deployments, infrastructure changes, or developer modifications. StackShield automatically checks for exposed Horizon dashboards as part of its security scan. If your Horizon endpoint returns a 200 response to an unauthenticated request, it flags it immediately.

If you are using Telescope in production as well, the same gating principles apply. See the full Telescope security guide for the equivalent setup, and check the Telescope exposure fix for step-by-step remediation.


Verification Checklist

After implementing these changes, run through this checklist:

Check How to verify Expected result
Gate configured Review HorizonServiceProvider::gate() Email allowlist or role check present
Provider registered Check bootstrap/providers.php HorizonServiceProvider listed
Unauthenticated access blocked curl -s -o /dev/null -w "%{http_code}" https://yourapp.com/horizon 403 or 302
API endpoints blocked curl -s -o /dev/null -w "%{http_code}" https://yourapp.com/horizon/api/stats 403 or 302
Web server block in place Review Nginx/Apache config IP restriction on /horizon
Job payloads minimized Review job constructors IDs instead of models where possible
Failed jobs cleaned up Check failed_jobs table size Reasonable retention policy

A properly secured Horizon dashboard gives you full visibility into your queue system without exposing sensitive data to unauthorized users. Gate it, block it at the web server level, and verify it continuously with automated scanning.

Frequently Asked Questions

What does Laravel Horizon expose if left ungated?

An ungated Horizon dashboard exposes your entire queue system: pending and completed job payloads (which may contain user emails, passwords, payment data), failed jobs with full exception traces and input data, worker status and configuration, queue metrics and throughput data, and Redis connection details. This is a significant data leak that can reveal both user data and internal system architecture.

How do I check if my Horizon dashboard is publicly accessible?

Open an incognito browser window and navigate to yourdomain.com/horizon. If you see the Horizon dashboard without being asked to log in, it is publicly accessible. You can also use curl: curl -s -o /dev/null -w "%{http_code}" https://yourdomain.com/horizon. A 200 response means it is exposed.

Should I disable Horizon entirely in production?

No. Unlike Telescope, which is a debugging tool, Horizon is a queue management system that actively supervises your workers. You need Horizon running in production to manage queue workers, retry failed jobs, and monitor throughput. The goal is to gate the dashboard so only authorized users can access it, not to disable Horizon itself.

Does StackShield detect exposed Horizon dashboards?

Yes. StackShield checks for publicly accessible Horizon dashboards as part of its standard security scan. If your Horizon dashboard returns a 200 response without authentication, StackShield flags it as a high-severity finding with specific remediation steps.

Stay Updated on Laravel Security

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