# 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.

**Author:** Matt King | **Published:** May 2, 2026 | **Category:** Security

---

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](/blog/laravel-telescope-production-security)), 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

```php
// 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
<?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:

```bash
# 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:**

```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:**

```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:

```php
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`:

```env
HORIZON_ALLOW_DASHBOARD=true
```

And in `config/horizon.php`:

```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:

```php
// 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:

```php
// 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:

```bash
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:

```php
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:

```php
// 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:

```php
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:

```php
// 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:

```php
// 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](/free-scan) 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](/blog/laravel-telescope-production-security) for the equivalent setup, and check the [Telescope exposure fix](/fix/telescope-exposed) 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](/free-scan).

---

## 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.

