Hangfire Dashboard Authorization: 401 in Production, Policies, and Read-Only
Contents
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 (checked October 30, 2026). For reading job state in code instead of through the UI, see Hangfire monitoring.
Why the Dashboard Returns 401 Off Localhost#
A new DashboardOptions() sets Authorization to a single LocalRequestsOnlyAuthorizationFilter (DashboardOptions.cs). UseHangfireDashboard() and MapHangfireDashboard() called without options use a DashboardOptions registered in DI if there is one, and that default otherwise (HangfireApplicationBuilderExtensions.cs, HangfireEndpointRouteBuilderExtensions.cs). The dashboard documentation states it directly: "By default Hangfire allows access to Dashboard pages only for local requests."
The filter reads HttpContext.Connection.RemoteIpAddress (AspNetCoreDashboardRequest.cs) and allows the request only when (LocalRequestsOnlyAuthorizationFilter.cs):
- the remote address is
127.0.0.1or::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):
| 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). Middleware runs in the order it is added (Microsoft middleware order), so call UseAuthentication before UseHangfireDashboard. Hangfire's "Method call order is important" note in the dashboard documentation 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):
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:
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). 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), 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:
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;
}
}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). 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). 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:
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.
MapHangfireDashboardWithAuthorizationPolicyandMapHangfireDashboardWithNoAuthorizationFiltersoverwriteAuthorizationandAsyncAuthorizationon the options object you give them. Put every access rule in the policy. MapHangfireDashboard(...).RequireAuthorization(...)keeps the local-only filter. PlainMapHangfireDashboarddoesn'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 setAuthorizationyourself.
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). Hangfire evaluates it once per request (DashboardContext.cs). This gives the HangfireViewer policy access, and buttons only to HangfireAdmin:
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, BatchCommandDispatcher.cs).
Read-only controls actions, not visibility. A read-only viewer still opens every page, and the dashboard documentation 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); 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). 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/opsintoRequest.PathBase. WithWebApplication, callUseRoutingafterUsePathBaseso routing sees the rewritten path (Microsoft proxy documentation).
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, DashboardRoutes.cs, LayoutPage.cshtml, hangfire.js). Hangfire monitoring covers when to add a schedule-based alert, Hangfire recurring job not running covers runs that never start, Hangfire job stuck in processing covers runs that start and never finish, and dead man's 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:
dotnet add package CronRadar.Hangfireusing 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, 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:reconcilerecurring 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. The Hangfire integration guide 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.
Put this guide to work
Connect your Hangfire job to monitoring
Follow the current integration guide, check the release limitations, and verify a real run and alert delivery.
NuGet currently installs CronRadar.Hangfire 0.1.0. Setup and discovery work, but this release can report failures before Hangfire finishes retrying, including cancellations. Reporting only after retries are exhausted requires the pending 0.2.1 release. Every reconciliation sets each discovered monitor's grace period to CronRadar.CronRadar.DefaultGracePeriodSeconds, 60 unless set in code, replacing dashboard edits.