Skip to content
Back to skills

Dotnet Background Jobs

ASecurity

Reviews ASP.NET Core background/scheduled job design. Flags hand-rolled BackgroundService loops used instead of Hangfire, job schedules (name, cron, enabled) hardcoded in code instead of sourced from a configurable store, an unauthenticated Background Jobs admin controller that lets callers register or trigger arbitrary jobs, non-idempotent job handlers despite Hangfire's at-least-once execution guarantee, and an unprotected Hangfire dashboard. Outputs findings with pilot-dotnet background-jo...

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 6, 2026
developmentgoc#sqlexpressapi

Works with

  • api

Security analysis

A100/100

Scanned September 6, 2026

npx -y skills add AgenticPawan/FullStack-Pilot --skill dotnet-background-jobs --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Dotnet Background Jobs?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Dotnet Background Jobs
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/agenticpawan-dotnet-background-jobs/badge)](https://www.skillsdirectory.com/skills/agenticpawan-dotnet-background-jobs)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: dotnet-background-jobs
description: Reviews ASP.NET Core background/scheduled job design. Flags hand-rolled BackgroundService loops used instead of Hangfire, job schedules (name, cron, enabled) hardcoded in code instead of sourced from a configurable store, an unauthenticated Background Jobs admin controller that lets callers register or trigger arbitrary jobs, non-idempotent job handlers despite Hangfire's at-least-once execution guarantee, and an unprotected Hangfire dashboard. Outputs findings with pilot-dotnet background-jobs standard IDs.
when_to_use: Hangfire, background job, recurring job, IRecurringJobManager, BackgroundService, cron schedule, job scheduling, BackgroundJobsController, Hangfire dashboard, idempotent job, fire-and-forget job, IDashboardAuthorizationFilter, scheduled task
---

## Standard IDs

| ID | Severity | What it checks |
|----|----------|-----------------|
| BGJ-001 | P0 | Custom `BackgroundService` + delay loop used instead of Hangfire for recurring/scheduled work |
| BGJ-002 | P1 | Job schedule (name/cron/enabled) hardcoded instead of sourced from a configurable store |
| BGJ-003 | P0 | Background-jobs admin endpoint with no `[Authorize]`/permission-policy guard |
| BGJ-004 | P1 | Job handler is not idempotent despite Hangfire's at-least-once execution guarantee |
| BGJ-005 | P2 | Hangfire dashboard mounted with no `IDashboardAuthorizationFilter` |

---

## Check A — Hand-rolled loop instead of Hangfire (BGJ-001)

### Detection

1. Grep for custom `BackgroundService`/`IHostedService` implementations that `while (!stoppingToken.IsCancellationRequested) { ...; await Task.Delay(...); }` to run periodic work.
2. This loses everything Hangfire provides for free: persistence across restarts, automatic retry with backoff, a dashboard, and distributed execution across multiple instances (without it, a scaled-out deployment runs the same job N times).

### BAD — custom polling loop

```csharp
public class InvoiceReminderService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await SendReminders();
            await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
            // No persistence, no retry, runs once per replica when scaled out.
        }
    }
}
```

### GOOD — Hangfire recurring job

```csharp
builder.Services.AddHangfire(config => config
    .UsePostgreSqlStorage(builder.Configuration.GetConnectionString("Hangfire"))); // or SQL Server storage
builder.Services.AddHangfireServer();

// Registered once at startup (or via the configurable job store — see Check B)
RecurringJob.AddOrUpdate<IInvoiceReminderJob>(
    "invoice-reminders",
    job => job.RunAsync(),
    Cron.Daily);
```

---

## Check B — Job schedule hardcoded instead of configurable (BGJ-002)

### Detection

1. Check whether cron expressions and job enablement live as C# literals/attributes in startup code, or are sourced from a configurable store (DB table or JSON config) that ops can edit without a redeploy.
2. Flag `RecurringJob.AddOrUpdate("job-name", ..., "0 3 * * *")` calls scattered through code with no single source of truth for `name`/`cron`/`enabled`.

### BAD — cron hardcoded per call site, no way to disable without a deploy

```csharp
RecurringJob.AddOrUpdate<IInvoiceReminderJob>("invoice-reminders", j => j.RunAsync(), "0 3 * * *");
RecurringJob.AddOrUpdate<ICleanupJob>("nightly-cleanup", j => j.RunAsync(), "0 2 * * *");
```

### GOOD — jobs defined in a configurable JSON/DB-backed schedule

```json
// jobs.config.json (or a JobSchedules DB table with the same shape)
[
  { "name": "invoice-reminders", "cron": "0 3 * * *", "enabled": true },
  { "name": "nightly-cleanup",   "cron": "0 2 * * *", "enabled": false }
]
```

```csharp
public interface IJobDefinition
{
    string Name { get; }
    void Register(); // maps Name -> the strongly-typed job invocation
}

public class BackgroundJobScheduler
{
    private readonly IEnumerable<IJobDefinition> _definitions;
    private readonly IJobScheduleStore _store; // reads jobs.config.json or the DB table

    public async Task SyncAsync()
    {
        foreach (var schedule in await _store.GetAllAsync())
        {
            var definition = _definitions.SingleOrDefault(d => d.Name == schedule.Name);
            if (definition is null) continue;

            if (schedule.Enabled)
                RecurringJob.AddOrUpdate(schedule.Name, () => definition.Invoke(), schedule.Cron);
            else
                RecurringJob.RemoveIfExists(schedule.Name);
        }
    }
}
```

---

## Check C — Unauthenticated background-jobs admin endpoint (BGJ-003)

### Detection

1. Find any controller that lets a caller register, trigger, or delete a Hangfire job (`BackgroundJobsController`, `/api/jobs/{name}/trigger`).
2. Flag any such endpoint with no `[Authorize]`, or `[Authorize]` with no fine-grained permission policy (see `dotnet-authorization` AZ-001/AZ-002) — this endpoint can run arbitrary scheduled work on demand and must be treated as a high-privilege admin surface.

### BAD — anyone can trigger or reschedule a job

```csharp
[ApiController]
[Route("api/jobs")]
public class BackgroundJobsController : ControllerBase
{
    [HttpPost("{name}/trigger")]
    public IActionResult Trigger(string name)
    {
        BackgroundJob.Enqueue(name); // no [Authorize] at all
        return Ok();
    }
}
```

### GOOD — permission-gated admin endpoint

```csharp
[ApiController]
[Route("api/jobs")]
[Authorize(Policy = "Jobs.Manage")]
public class BackgroundJobsController : ControllerBase
{
    [HttpPost("{name}/trigger")]
    public async Task<IActionResult> Trigger(string name, CancellationToken ct)
    {
        if (!await _jobCatalog.ExistsAsync(name)) return NotFound();
        BackgroundJob.Enqueue(name);
        return Accepted();
    }
}
```

---

## Check D — Job handler not idempotent (BGJ-004)

### Detection

Hangfire guarantees *at-least-once* execution — a job can run twice after a crash/retry. Check whether a job handler is safe to run twice (upsert/dedupe-key check) or whether it performs a non-idempotent side effect (send-email, charge-payment, increment-counter) with no guard.

### BAD — sending a reminder twice on retry double-charges/double-emails

```csharp
public async Task RunAsync()
{
    var overdue = await _db.Invoices.Where(i => i.IsOverdue).ToListAsync();
    foreach (var invoice in overdue)
        await _emailSender.SendReminderAsync(invoice); // retried job = duplicate emails
}
```

### GOOD — idempotency guard via a processed-marker

```csharp
public async Task RunAsync()
{
    var overdue = await _db.Invoices.Where(i => i.IsOverdue && i.ReminderSentAt == null).ToListAsync();
    foreach (var invoice in overdue)
    {
        await _emailSender.SendReminderAsync(invoice);
        invoice.ReminderSentAt = DateTime.UtcNow; // next retry/run skips it
    }
    await _db.SaveChangesAsync();
}
```

---

## Check E — Hangfire dashboard unprotected (BGJ-005)

### Detection

Check `app.UseHangfireDashboard(...)` for an `Authorization` filter. The default (no filter, or the sample `LocalRequestsOnlyAuthorizationFilter`) allows anyone reaching the route to view/manage all jobs in non-local environments.

### BAD — dashboard mounted with no auth filter in a deployed environment

```csharp
app.UseHangfireDashboard("/hangfire"); // default filter only allows localhost — often forgotten when tunneled/proxied
```

### GOOD — dashboard gated by an authorization filter tied to real auth

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

public class HangfireAdminAuthorizationFilter : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        var httpContext = context.GetHttpContext();
        return httpContext.User.Identity?.IsAuthenticated == true
            && httpContext.User.IsInRole("Admin");
    }
}
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…