skip to content

Why does the Laravel Horizon dashboard at /horizon return 403 in production after install, and how does the viewHorizon gate grant access?

level: juniorimportance: should knowfreq 35%

answer

  1. open in local only
  2. App\Providers\HorizonServiceProvider gate()
  3. Gate::define('viewHorizon', ...)
  4. empty email list denies everyone
  5. Horizon::auth replaces the check

basics

~20 s

Outside the local environment Horizon's dashboard is allowed only when the viewHorizon gate passes, and the gate published into App\Providers\HorizonServiceProvider starts with an empty email list, so everyone gets 403 until you fill it in.

solid answer

~40 s

Every Horizon route runs Horizon's authenticate middleware, which calls `Horizon::check()`. The base `HorizonApplicationServiceProvider` sets that check to `Gate::check('viewHorizon', [$request->user()]) || app()->environment('local')`, so locally anyone gets in, and elsewhere the gate decides; a failure throws a 403. `php artisan horizon:install` publishes `App\Providers\HorizonServiceProvider` with a `gate()` method defining `viewHorizon` as `in_array(optional($user)->email, [])`: the list is empty, so nobody passes in production. Fix it by editing that gate, preferably to check a role or permission on the user, and make sure the `web` middleware in `horizon.middleware` is there so the session user is resolved. To authorize by something other than the gate, call `Horizon::auth()` with your own callback.

code

php · 17 lines
php
<?php

namespace App\Providers;

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

class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
    protected function gate(): void
    {
        Gate::define('viewHorizon', function (?User $user = null) {
            return $user?->is_admin === true;
        });
    }
}

go deeper

for a junior

Know that /horizon is open only locally and that production access is granted in the viewHorizon gate inside App\Providers\HorizonServiceProvider.

for a middle

Explain the chain from the authenticate middleware to Horizon::check, the local-environment fallback and the empty published allow-list.

for a senior

Replace the allow-list with a role check, decide between redirecting guests and returning 403, and review who can retry jobs from the dashboard.

for a principal

Treat operational dashboards as privileged surfaces: decide who sees job payloads in production and how that access is granted and revoked.

## Where the dashboard lives Horizon registers its dashboard and JSON API under the `path` option of `config/horizon.php` (`horizon` by default, overridable with `HORIZON_PATH`), optionally on a `domain`. All of these routes share a `horizon` middleware group made of Horizon's own internal middleware plus whatever you list in the `middleware` option, which is `['web']` in the published file. The `web` group matters: it starts the session, so `$request->user()` can see who is logged in. ## How the access check works 1. Each dashboard request passes through Horizon's `Authenticate` middleware. 2. That middleware calls `Horizon::check($request)`. If it returns false, Horizon throws a `ForbiddenException`, an HTTP exception with status **403**. 3. `Horizon::check()` runs the callback registered with `Horizon::auth()`. With no callback at all, it allows only the `local` environment. 4. The base class `Laravel\Horizon\HorizonApplicationServiceProvider` registers this callback in its `boot()` method: ```php Horizon::auth(function ($request) { return Gate::check('viewHorizon', [$request->user()]) || app()->environment('local'); }); ``` So: in `local`, everyone is let in; everywhere else, the `viewHorizon` **gate** decides. ## Why production answers 403 `php artisan horizon:install` publishes `app/Providers/HorizonServiceProvider.php`, which extends that base class, and registers it in `bootstrap/providers.php`. Its `gate()` method defines the ability like this: ```php Gate::define('viewHorizon', function ($user = null) { return in_array(optional($user)->email, [ // ]); }); ``` The allow-list is empty, so the gate returns false for every user, and every non-local request gets 403. This is secure by default on purpose: the dashboard shows job payloads, tags, failed-job exceptions and lets people retry jobs. ## Granting access properly - **Edit the gate**, not the environment check. An email allow-list works for a solo project; a role or permission check on the user model scales better and needs no deploy to add a person. - **Keep authentication in front.** Because the published closure accepts `$user = null`, guests reach the gate and are simply denied. If you want guests redirected to the login page instead of shown a 403, add your auth middleware to the `middleware` option alongside `web`. - **Do not widen it with `app()->environment()`.** Adding `production` to the environment branch makes the dashboard public. - **Different mechanism?** Call `Horizon::auth(fn ($request) => ...)` in your provider's `boot()` to replace the check entirely, for example to combine an IP allow-list with a signed-in user. | Situation | Result | |---|---| | `APP_ENV=local`, any visitor | allowed | | production, guest | gate returns false, 403 | | production, user not on the list | 403 | | production, user the gate accepts | allowed | ## What the dashboard lets a viewer do The gate is not only about reading graphs. The dashboard's routes let an authorized user: - read full job payloads, including the serialized arguments and any failure exception; - retry failed jobs and retry failed batches; - start and stop monitoring tags; - see which machines and supervisors are running and how many processes each queue has. Because job payloads can contain customer identifiers and failure traces, treat `viewHorizon` like any other operator permission: grant it to named roles, review it, and never widen it to everyone who can log in. If the application sends a Content Security Policy, `Horizon::cspNonce()` lets Horizon's own script and style tags carry your nonce so the dashboard still renders under that policy. ## What this question does not cover How to write gates and policies in general, and how guards resolve the user, belong to Laravel's authorization topics; here the point is the Horizon-specific wiring: the `viewHorizon` ability name, the published provider, the local-only fallback and `Horizon::auth()`. ## Common confusion Developers often see the dashboard work on their laptop and assume it is misconfigured in production. It is behaving as designed: the laptop passes on `environment('local')`, not on the gate. Testing the gate locally means temporarily running with a non-local environment, or asserting `Gate::forUser($user)->allows('viewHorizon')` in a test.

  • Why does the dashboard work on a developer laptop even with an empty viewHorizon list?
    Horizon's authorization callback is `Gate::check('viewHorizon', ...) || app()->environment('local')`. On a laptop `APP_ENV` is `local`, so the second half lets everyone in and the gate is never the deciding factor.
  • How would you protect the dashboard by IP address instead of a login?
    Either keep the gate but make its closure accept a null user and check the request IP inside it, or call `Horizon::auth()` in the provider's `boot()` with a callback that inspects `$request->ip()`. The nullable user matters because a non-nullable parameter makes the gate deny guests before your logic runs.

saying these in an interview costs you the question

  • The Horizon dashboard is public by default and must be locked down
  • Adding production to the environment check is the right way to open it
  • A 403 on /horizon means the web middleware is missing
  • viewHorizon is a Horizon config key rather than a gate ability
  • Horizon::auth adds a second check on top of the gate