# Hangfire Dashboard Authorization: 401 in Production, Policies, and Read-Only

By Dan Ochoa · Published 2026-10-30 · Updated 2026-10-30
Canonical: https://cronradar.com/blog/hangfire-dashboard-authorization

> Why the Hangfire 1.8 dashboard returns 401 off localhost, how to write IDashboardAuthorizationFilter or use a policy, and how to set read-only and proxy paths.

**The Hangfire dashboard returns 401 in production because its default authorization filter, `LocalRequestsOnlyAuthorizationFilter`, lets in only requests that come from the same machine.** Replace it with your own `IDashboardAuthorizationFilter`, or map the dashboard with `MapHangfireDashboardWithAuthorizationPolicy` so an ASP.NET Core policy decides. Add `IsReadOnlyFunc` for people who should look but not delete or requeue.

This guide covers Hangfire.Core and Hangfire.AspNetCore 1.8.25, the current release on [NuGet](https://www.nuget.org/packages/Hangfire.AspNetCore/1.8.25) (checked October 30, 2026). For reading job state in code instead of through the UI, see [Hangfire monitoring](https://cronradar.com/blog/hangfire-monitoring).

## Why the Dashboard Returns 401 Off Localhost

A `new DashboardOptions()` sets `Authorization` to a single `LocalRequestsOnlyAuthorizationFilter` ([DashboardOptions.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/DashboardOptions.cs)). `UseHangfireDashboard()` and `MapHangfireDashboard()` called without options use a `DashboardOptions` registered in DI if there is one, and that default otherwise ([HangfireApplicationBuilderExtensions.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/HangfireApplicationBuilderExtensions.cs), [HangfireEndpointRouteBuilderExtensions.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/HangfireEndpointRouteBuilderExtensions.cs)). The [dashboard documentation](https://docs.hangfire.io/en/latest/configuration/using-dashboard.html) states it directly: "By default Hangfire allows access to Dashboard pages only for local requests."

The filter reads `HttpContext.Connection.RemoteIpAddress` ([AspNetCoreDashboardRequest.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/Dashboard/AspNetCoreDashboardRequest.cs)) and allows the request only when ([LocalRequestsOnlyAuthorizationFilter.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/LocalRequestsOnlyAuthorizationFilter.cs)):

- the remote address is `127.0.0.1` or `::1`,
- the remote address equals the local address of the connection, or
- the remote address parses as a loopback address, such as `::ffff:127.0.0.1`.

An empty remote address is treated as not local. So the dashboard works on your machine and fails as soon as the browser is somewhere else.

### 401 or 403: what the status code tells you

When any filter returns `false`, the dashboard middleware sets the status code and writes nothing else ([AspNetCoreDashboardMiddleware.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/Dashboard/AspNetCoreDashboardMiddleware.cs)):

| Response         | Cause in Hangfire 1.8.25                                                                                        |
| ---------------- | --------------------------------------------------------------------------------------------------------------- |
| 401, empty body  | A filter refused and `HttpContext.User` is not authenticated                                                    |
| 403, empty body  | A filter refused and the user is authenticated                                                                  |
| 403 on a POST    | `IAntiforgery` is registered and the anti-forgery token is missing or invalid (unless `IgnoreAntiforgeryToken`) |
| 401 on an action | `IsReadOnlyFunc` returned `true` and someone tried to delete, requeue, or trigger something                     |
| 404              | The path under the dashboard prefix matches no dashboard route (endpoint-mapped dashboard only)                 |

The middleware does not call your authentication scheme's challenge, so a filter refusal never redirects to a login page. If you are signed in and a page load (not a button action) gets 401, Hangfire saw an unauthenticated `HttpContext.User`. A 401 on delete, requeue, or trigger means `IsReadOnlyFunc` returned `true` ([CommandDispatcher.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/CommandDispatcher.cs)). Middleware runs in the order it is added ([Microsoft middleware order](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/#middleware-order)), so call `UseAuthentication` before `UseHangfireDashboard`. Hangfire's "Method call order is important" note in the [dashboard documentation](https://docs.hangfire.io/en/latest/configuration/using-dashboard.html) gives the same rule for OWIN Startup classes.

## Replace the Filter With IDashboardAuthorizationFilter

`IDashboardAuthorizationFilter` has one method, `bool Authorize(DashboardContext context)`, and `GetHttpContext()` from Hangfire.AspNetCore gives you the ASP.NET Core `HttpContext` ([AspNetCoreDashboardContextExtensions.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/Dashboard/AspNetCoreDashboardContextExtensions.cs)):

```csharp
using Hangfire.Dashboard;

public sealed class HangfireAdminFilter : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        var user = context.GetHttpContext().User;

        return user.Identity?.IsAuthenticated == true
            && user.IsInRole("HangfireAdmin");
    }
}
```

Assigning `Authorization` replaces the default list, so the local-only filter is gone:

```csharp
using Hangfire;

app.UseAuthentication();
app.UseAuthorization();

app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization = new[] { new HangfireAdminFilter() }
});
```

When you pass several filters, every one must return `true` ([documentation](https://docs.hangfire.io/en/latest/configuration/using-dashboard.html)). The middleware runs all synchronous filters first, then the async ones, and stops at the first refusal.

### The async variant

Use `IDashboardAsyncAuthorizationFilter`, whose single method is `Task<bool> AuthorizeAsync(DashboardContext context)` ([IDashboardAsyncAuthorizationFilter.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/IDashboardAsyncAuthorizationFilter.cs)), when the check has to await something, such as a policy evaluation or a lookup. This one evaluates a named ASP.NET Core policy through [`IAuthorizationService.AuthorizeAsync`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authorization.authorizationserviceextensions.authorizeasync):

```csharp
using Hangfire.Dashboard;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;

public sealed class HangfirePolicyFilter : IDashboardAsyncAuthorizationFilter
{
    public async Task<bool> AuthorizeAsync(DashboardContext context)
    {
        var httpContext = context.GetHttpContext();
        var authorization = httpContext.RequestServices
            .GetRequiredService<IAuthorizationService>();

        var result = await authorization.AuthorizeAsync(httpContext.User, "HangfireViewer");
        return result.Succeeded;
    }
}
```

```csharp
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    AsyncAuthorization = new[] { new HangfirePolicyFilter() }
});
```

Setting `AsyncAuthorization` also clears `Authorization`, but only while `Authorization` still holds the built-in default ([DashboardOptions.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/DashboardOptions.cs)). If you set both, both lists run.

## Use an ASP.NET Core Authorization Policy

With endpoint routing, Hangfire.AspNetCore has three mapping methods ([HangfireEndpointRouteBuilderExtensions.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/HangfireEndpointRouteBuilderExtensions.cs)). `MapHangfireDashboard` has two overloads: one without a pattern, which maps `/hangfire`, and one whose `pattern` is required. The other two default `pattern` to `"/hangfire"`.

| Method                                                                                             | Filters that run                                               |
| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `MapHangfireDashboard(options, storage)` or `MapHangfireDashboard(pattern, options, storage)`      | Whatever `options` holds, local-only by default                |
| `MapHangfireDashboardWithNoAuthorizationFilters(pattern = "/hangfire", options, storage)`          | None: it empties `Authorization` and `AsyncAuthorization`      |
| `MapHangfireDashboardWithAuthorizationPolicy(policyName, pattern = "/hangfire", options, storage)` | None, plus `.RequireAuthorization(policyName)` on the endpoint |

The policy method is the shortest route to a production setup:

```csharp
using Hangfire;

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("HangfireViewer", policy => policy
        .RequireAuthenticatedUser()
        .RequireRole("HangfireAdmin", "Operations"));
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapHangfireDashboardWithAuthorizationPolicy("HangfireViewer");
```

Two consequences follow from the source:

- **Filters you pass are discarded.** `MapHangfireDashboardWithAuthorizationPolicy` and `MapHangfireDashboardWithNoAuthorizationFilters` overwrite `Authorization` and `AsyncAuthorization` on the options object you give them. Put every access rule in the policy.
- **`MapHangfireDashboard(...).RequireAuthorization(...)` keeps the local-only filter.** Plain `MapHangfireDashboard` doesn't touch the filters, so a user who passes your policy from a remote browser still gets 401 or 403 from Hangfire. Use the policy method, or set `Authorization` yourself.

`MapHangfireDashboardWithNoAuthorizationFilters` with no `RequireAuthorization` after it leaves the dashboard open to anyone who can reach the URL.

## Make the Dashboard Read-Only for Some Users

`IsReadOnlyFunc` takes a `Func<DashboardContext, bool>` and defaults to `_ => false`, so everyone who gets in has full control ([DashboardOptions.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/DashboardOptions.cs)). Hangfire evaluates it once per request ([DashboardContext.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/DashboardContext.cs)). This gives the `HangfireViewer` policy access, and buttons only to `HangfireAdmin`:

```csharp
using Hangfire;
using Hangfire.Dashboard;

app.MapHangfireDashboardWithAuthorizationPolicy("HangfireViewer", options: new DashboardOptions
{
    IsReadOnlyFunc = context =>
        !context.GetHttpContext().User.IsInRole("HangfireAdmin")
});
```

The documentation describes the read-only view as one that "prevents users from changing anything, such as deleting or enqueueing jobs." It is enforced on the server too: the command dispatchers behind those buttons return 401 when `IsReadOnly` is `true` ([CommandDispatcher.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/CommandDispatcher.cs), [BatchCommandDispatcher.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/BatchCommandDispatcher.cs)).

Read-only controls actions, not visibility. A read-only viewer still opens every page, and the [dashboard documentation](https://docs.hangfire.io/en/latest/configuration/using-dashboard.html) warns that the dashboard "exposes sensitive information about your background jobs, including method names and serialized arguments." Decide who gets in with authorization. `DisplayStorageConnectionString` also defaults to `true`; set it to `false` if viewers shouldn't see which storage the dashboard reads.

## Behind a Reverse Proxy or Path Base

### A same-host proxy makes every request look local

`LocalRequestsOnlyAuthorizationFilter` trusts the connection's remote address. When Nginx, Caddy, or another proxy on the same host forwards to Kestrel over `127.0.0.1` and nothing rewrites the address, the remote address is the proxy's loopback address, and the filter allows **every** visitor. When the proxy runs on another machine or container, the remote address is that proxy's, and the filter refuses everyone: 401 for anonymous visitors, 403 for signed-in users.

ASP.NET Core's Forwarded Headers Middleware can rewrite `HttpContext.Connection.RemoteIpAddress` from `X-Forwarded-For`, and by default it accepts those headers only from loopback proxies ([Microsoft proxy documentation](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer)); a proxy elsewhere has to be added to `KnownProxies` or `KnownNetworks`. Either way, an IP check is the wrong control for a production dashboard. Replace the default with a filter or policy that checks the signed-in user.

### Links break under a stripped prefix

Every dashboard link is built as `PrefixPath + Request.PathBase + relativePath` ([UrlHelper.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/UrlHelper.cs)). If the public URL is `https://example.com/ops/hangfire` and the proxy strips `/ops` before forwarding, the app never sees `/ops`, so the dashboard's CSS, scripts, and links point at `/hangfire/...` and fail. Two fixes, depending on what the proxy forwards:

- **The proxy strips the prefix:** set `PrefixPath = "/ops"`. Its source comment describes the result as `{domain}/{PrefixPath}/{hangfire}`.
- **The proxy keeps the prefix:** call `app.UsePathBase("/ops")`, which moves `/ops` into `Request.PathBase`. With `WebApplication`, call `UseRouting` after `UsePathBase` so routing sees the rewritten path ([Microsoft proxy documentation](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer)).

```csharp
app.MapHangfireDashboardWithAuthorizationPolicy("HangfireViewer", options: new DashboardOptions
{
    // The proxy serves https://example.com/ops/hangfire and strips /ops.
    PrefixPath = "/ops",
    // The Back to site link; null hides it.
    AppPath = "/ops/"
});
```

`AppPath` defaults to `/`. To move the dashboard itself, change the pattern: `UseHangfireDashboard("/jobs")` or `MapHangfireDashboardWithAuthorizationPolicy("HangfireViewer", "/jobs")`.

## What the Dashboard Cannot Do

The dashboard reads storage and renders a page only when someone requests one. Its live counters update by polling `/stats` from an open page every `StatsPollingInterval`, 2,000 milliseconds by default, and no `DashboardOptions` setting sends a notification ([DashboardOptions.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/DashboardOptions.cs), [DashboardRoutes.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/DashboardRoutes.cs), [LayoutPage.cshtml](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/Pages/LayoutPage.cshtml), [hangfire.js](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/Content/js/hangfire.js)). [Hangfire monitoring](https://cronradar.com/blog/hangfire-monitoring) covers when to add a schedule-based alert, [Hangfire recurring job not running](https://cronradar.com/blog/hangfire-recurring-job-not-running) covers runs that never start, [Hangfire job stuck in processing](https://cronradar.com/blog/hangfire-job-stuck-processing) covers runs that start and never finish, and [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch) explains why the check that a run happened belongs outside the process.

## Alert on Missed and Failed Runs With CronRadar

CronRadar is a cron-only monitoring service. It knows each recurring job's schedule and alerts when a run fails or hasn't arrived by the end of its grace period, by email, Slack, webhook, Discord, or PagerDuty. The alert comes from CronRadar, not from the Hangfire host, so it still fires when that host is down.

Store the CronRadar application key as `CRONRADAR_API_KEY` in your environment or secret store, not in `appsettings.json` or any committed file. Then install the package in the host that runs the Hangfire server:

```bash
dotnet add package CronRadar.Hangfire
```

```csharp
using CronRadar.Hangfire;

// After your existing AddHangfire(...) and AddHangfireServer() calls.
// Reads the key from the CRONRADAR_API_KEY environment variable.
builder.Services.AddCronRadarHangfire();
```

In the published 0.1.0 ([NuGet](https://www.nuget.org/packages/CronRadar.Hangfire/0.1.0), checked October 30, 2026), that call adds an execution filter that reports each recurring job's start, completion, and failure, and a hosted service that declares every recurring job and its cron expression at startup. Monitoring errors are caught, so they can't fail a job. A recurring job with no start after its expected time goes from `warning` to `critical` once the grace period passes. To exclude a job, put `[SkipMonitor]` on its method.

Two things show up in the dashboard you just secured:

- **A `cronradar:reconcile` recurring job.** The package registers it to re-declare the registry every 15 minutes by default (`reconcileIntervalMinutes`, clamped to 1 through 59). If an admin deletes it from the Recurring Jobs page, it is registered again the next time the host starts. A read-only viewer can't delete it.
- **Early failure reports.** CronRadar.Hangfire 0.1.0 can report a failure before Hangfire finishes retrying, including cancellations, so a job that later succeeds on retry can still alert. Reporting only after retries are exhausted requires the pending 0.2.1 release.

CronRadar evaluates schedules in UTC; for jobs with a non-UTC `RecurringJobOptions.TimeZone`, see [Hangfire cron expressions and time zones](https://cronradar.com/blog/hangfire-cron-time-zones). The [Hangfire integration guide](https://docs.cronradar.com/integrations/hangfire) has the full setup and the current release notice. Keep the dashboard for retries, requeues, and debugging; CronRadar tells you when to open it.

## Frequently Asked Questions

### Why does the Hangfire dashboard work locally but return 401 in production?

The default `DashboardOptions.Authorization` is `LocalRequestsOnlyAuthorizationFilter`, which allows only loopback addresses or a remote address equal to the local one. A browser on another machine fails it and, unauthenticated, gets 401. Replace the filter or use `MapHangfireDashboardWithAuthorizationPolicy`.

### Why do I get 403 instead of 401?

Hangfire returns 403 when a filter refuses a user who is already authenticated, and 401 when the user isn't. A 403 on a button click can also be a failed anti-forgery check.

### Can I use RequireAuthorization with MapHangfireDashboard?

You can, but `MapHangfireDashboard` keeps the local-only default filter, so remote users are still refused. `MapHangfireDashboardWithAuthorizationPolicy` removes the filters and applies `RequireAuthorization` for you.

### Does read-only mode hide job arguments?

No. `IsReadOnlyFunc` blocks deleting, requeuing, and triggering, and the server rejects those commands with 401. Every page stays visible, so restrict who gets in with authorization.

### Is it safe to expose the dashboard behind Nginx on the same server?

Not with the default filter. Unless forwarded headers rewrite the remote address, the proxy connects from `127.0.0.1`, so `LocalRequestsOnlyAuthorizationFilter` lets every visitor in. Use a filter or policy that checks the signed-in user.

### Can the Hangfire dashboard send alerts?

No. It renders pages on request and has no notification setting. Use an external check, such as CronRadar, to be told when a recurring job fails or doesn't run.
