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.
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.
Related Articles
Laravel Debug Mode Left On in Production? Here's What Attackers See (and How to Fix It)
APP_DEBUG=true in production exposes your database credentials, API keys, environment variables, and full stack traces to anyone who triggers an error. Here's exactly what gets leaked, how attackers find it, and the 2-minute fix.
SecurityOWASP Top 10 in Laravel: Real Vulnerabilities, Real Code Fixes (2026)
SQL injection through raw queries. XSS from unescaped Blade output. CSRF bypasses on API routes. Every OWASP Top 10 category mapped to Laravel-specific vulnerabilities with code you can copy to fix them.
SecurityIs Your Laravel .env File Exposed? How to Check and Fix It
Your .env file contains database credentials, API keys, and encryption secrets. If it's accessible from the web, attackers already have everything they need. Here's how to check and fix it.
Compare StackShield
Security Checklists
Laravel Production Deployment Security Checklist
A comprehensive security checklist for deploying Laravel applications to production. Covers environment config, server hardening, access control, and monitoring.
20 itemsLaravel API Security Checklist
Secure your Laravel API endpoints against common vulnerabilities. Covers authentication, input validation, rate limiting, and response security.
Stay Updated on Laravel Security
Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.