Table of Contents

A Background Job Should Not Inherit Everything the User Could Do

Pattern classification: General learning material

Difficulty: Intermediate

Prerequisites: No formal prerequisites. Familiarity with ASP.NET Core authorization, BackgroundService or a job library, and message queues is helpful. No AsiBackbone package or prior Learning material is required.

Sample scope: The C# targets .NET 8 or later. The stores, directory, queue, builder, and test harness are illustrative interfaces and fakes, not a runnable sample or a recommended persistence API. Framework and library behavior is described from the vendors' documentation as of November 2026; check the current documentation for your versions.

What this article covers: the two opposite ways a job inherits too much; what a queue message should and should not carry; how to separate who asked, who executes, what was accepted, how the work is delivered, and what is checked before it starts; a Generate Report flow from HTTP request to queue to worker; which changes should stop a waiting job; reusing ASP.NET Core authorization handlers in a worker; which actor belongs in audit evidence; when the downstream service really does need the user's delegated access; when a stored job identifier plus current authorization is enough; and when it is not.

In plain terms: you ask a records clerk to pull one file overnight. Handing the clerk your badge gives them every door you can open, until the badge stops working. Telling them to use the master key is worse: now any slip of paper with your name on it can open any cabinet. The ordinary answer is a request form. The front desk checks that you may have that file and logs the request. In the morning, the clerk uses their own key, confirms the request is still on file and you are still entitled to it, pulls that one file, and signs the log under their own name, recording you as the requester.

Consider a hypothetical multi-tenant billing application. An analyst can click Generate Report to produce a PDF statement for a customer account. Statements are slow to build, so the endpoint authorizes the request and puts the work on a queue. The team's first version forwards what it already has: the analyst's access token goes into the message, and the worker calls the billing API with it.

At month end the queue backs up for three hours. In this deployment access tokens live for one hour, so they expire in the queue, and jobs start failing with 401. Someone proposes storing refresh tokens in the messages, which alarms everyone enough to prompt a redesign. The second version gives the worker its own service identity with read access to billing data, and the failures stop.

Three months later, a review finds three things. An analyst who was removed from an account on Tuesday received its statement on Thursday, because a job requested on Monday waited behind a stuck batch. Statements contain vendor bank details that the analyst's screen masks, because the worker can read them and the old API would have refused. And every record says the report was generated by svc-report-worker, so nobody can say from the data who asked for what.

Both versions made the same mistake in opposite directions. The first let the job inherit everything the user could do. The second let it inherit everything the worker could do.

A queued job should carry a reference to an accepted operation, not anyone's authority. Record what was accepted, let the worker authenticate as itself, and have it confirm that this requester's operation is still allowed before it starts. The worker's identity sets the outer limit of what it can reach. The accepted operation sets what this job may do.

That does not mean every background job needs a new token or a new service. For many jobs, a row in a table and an ordinary authorization check in the worker are the whole design. Near the end, this article covers when that is enough and when it is not.


The Example: Generating One Statement

The rest of the article uses one flow. These are its rules:

Rule Depends on Authoritative source
The analyst may generate statements at all Current permissions The tenant's role assignments
The account belongs to the analyst's tenant Account and tenant The account record; the tenant from authentication, never from the request
The analyst is assigned to the account Current assignments The assignment table, which changes during the year
The statement includes bank details only if the analyst may view them Current field-level permission The tenant's role assignments
No statement is generated for an account under legal hold Account state The account record
No statement is generated for a suspended tenant Tenant status The tenant registry, which the directory reads when it returns the analyst's facts

The components are ordinary:

  • The Billing API, an ASP.NET Core application where the analyst signs in.
  • A queue, or a job library's storage, which delivers work.
  • A report worker, a BackgroundService in a separate process, with its own workload identity.
  • A report store, where finished statements wait to be downloaded.

The requester and the worker are in the same organization and the same trust boundary, and the worker reads the same database as the API. Those facts matter. The design changes when they stop being true.


Two Ways to Inherit Too Much

The job inherits the user

// Anti-pattern: the message carries the user's authority.
app.MapPost("/reports", async (StatementRequest request, HttpContext http, IReportQueue queue, CancellationToken ct) =>
    {
        // Requires the handler to keep the token: SaveToken for JWT bearer, SaveTokens for OpenID Connect.
        string? token = await http.GetTokenAsync("access_token");

        await queue.EnqueueAsync(new GenerateReportMessage(
            request.AccountId,
            request.Period,
            UserId: http.User.FindFirstValue("sub"),
            TenantId: http.User.FindFirstValue("tid"),
            Roles: http.User.FindAll("role").Select(claim => claim.Value).ToArray(),
            AccessToken: token), ct);

        return Results.Accepted();
    })
    .RequireAuthorization("reports.generate");

A worker needs to build one statement. This message gives it, and anything else that can read the message, a good deal more:

  • Everything the token allows, not one report. A bearer token can be used by any party in possession of it. Whatever scopes the token carries are available for its lifetime. A well-scoped token limits the damage, but it was scoped for the user's request, not for this one report.
  • More places to steal it. Queue dashboards, dead-letter queues, diagnostic logs, backups, and job-library storage now hold a usable credential. Hangfire, for example, serializes job arguments to JSON and stores them. Microsoft's On-Behalf-Of documentation gives a similar warning about tokens issued to a middle tier: do not send them anywhere except their intended audience.
  • A lifetime set by the wrong clock. Whether the job can run depends on the token's expiry, not on whether the operation is still allowed. An expired token kills a legitimate report. A token that is still valid carries an analyst's access past a change the token does not reflect.
  • Copied claims with no proof behind them. Roles and TenantId are now whatever the message says. Anyone who can write to the queue can write "role": "BillingAdmin". The worker has no issuer signature to verify for those values, and they are a snapshot that can go stale while the job waits.

Microsoft's advice for background work in ASP.NET Core is to copy the data you need during request processing and pass it to the task, rather than touching HttpContext. That is correct, and it is about thread safety. Copying sub and role into a message makes them available to the worker. It does not keep them true, and it does not make them proof.

The job inherits the worker

After the token failures, the worker gets its own identity, and the code still needs to read data with no user present. In .NET this often goes wrong quietly, through a "current user" service:

// Anti-pattern: no HTTP request is treated as "the system, with no limits."
// _currentTenant below is this service, injected into the DbContext.
public sealed class CurrentTenant(IHttpContextAccessor http) : ICurrentTenant
{
    public string? TenantId => http.HttpContext?.User.FindFirstValue("tid");
}

modelBuilder.Entity<Invoice>().HasQueryFilter(
    invoice => _currentTenant.TenantId == null || invoice.TenantId == _currentTenant.TenantId);

The == null was added so background jobs would keep working. A hosted service has no HttpContext, so in the worker the filter matches every tenant. The job then trusts the message:

// Anti-pattern: delivery plus the worker's reach is the only authorization.
await foreach (GenerateReportMessage message in queue.ReadAllAsync(stoppingToken))
{
    List<Invoice> invoices = await db.Invoices
        .Where(invoice => invoice.AccountId == message.AccountId)
        .ToListAsync(stoppingToken);

    await reports.PutAsync(builder.Build(invoices, includeBankDetails: true), stoppingToken);
}

This is the confused deputy problem: a more privileged component performs an action for someone who could not perform it directly. Nothing in this code asks whether the analyst may see this account, and the worker's permission to read billing data answers a different question. The worker's identity is real and correctly authenticated. It proves which process is running. It does not prove that any particular report was requested by someone allowed to have it.


Five Things That Cross the Boundary

Both versions merge responsibilities that have different owners and different lifetimes. Naming them makes the design easier to see.

Responsibility In the example Question it answers What it must not become
Requester identity analyst-17 in tenant-north Who asked, and on whose behalf is this work being done? A credential the worker uses
Worker identity svc-report-worker, authenticated by its platform Which process is running, and what can it technically reach? Proof that this operation is allowed
Accepted operation Job J-8841: the Q3 statement for account A-204, recorded after the API authorized the request Exactly what was requested and accepted? A permanent permission that outlives the facts behind it
Job delivery Queue message { JobId: J-8841 } Is there work to look at? Authority. Delivery can repeat, arrive late, or come from a misconfigured producer
Check before the work starts The worker re-evaluates policy against current facts Is this operation still allowed now? A copy of the request-time answer

The question "what should the job carry?" gets much easier once these are apart. It should carry a way to find the accepted operation, and nothing that grants anything.

What a message could carry What it gives the worker Assessment
The user's access token The user's scopes, for the token's lifetime, to anyone who can read the message Do not do this
The user's refresh token The ability to keep obtaining user tokens, subject to the issuer's rotation, expiry, and revocation rules Do not do this. A more valuable secret in the same exposed places
Copied claims or roles Unverifiable, stale assertions Do not treat them as authority
A service credential Whatever that service can do, to anyone who can read the message Do not do this. The worker gets its credentials from its platform
Tenant, account, and options as trusted values Whatever the producer wrote Useful for routing and diagnostics, never as the facts the job acts on
A job identifier The ability to ask about a job that already exists The default

An identifier-only message also shrinks what a queue writer can do. A forged or replayed message can point at a job that already exists. It cannot describe a new one, change the account, or add a role. That moves the authority question to who can write job rows, which is a far easier thing to restrict than who can touch a queue. Hangfire's guidance reaches a similar shape for different reasons: store the values in your application database and pass identifiers to the job, because jobs may run days later and arguments go stale.


The Same Flow With Each Responsibility in Its Place

HTTP request
    → Authenticate analyst-17, tenant from the validated token
    → Read account, assignment, and permissions from their own stores
    → Policy allows: record job J-8841 and the decision, in one transaction with an outbox row
    → 202 Accepted, with a status URL

Queue
    → Delivers { JobId: J-8841 }. No credential, no trusted values

Worker (own workload identity, possibly hours later)
    → Load J-8841 from the database; nothing else comes from the message
    → Create a scope bound to the job's tenant
    → Read current account, assignment, and permissions for the recorded requester
    → Same policy implementation: allowed? If not, reject the job and stop
    → Claim the job with a conditional update. A duplicate delivery or cancellation loses here
    → Build only what the decision allows, using the worker's own data access
    → Store the statement and record both identities

The request records an operation

The entry path establishes who is asking. The tenant comes from the validated token, and every other fact comes from the store that owns it. The claim names depend on your identity provider and claim mapping.

public sealed record Requester(string UserId, string TenantId);

public sealed record ReportJobReady(Guid JobId);   // the whole queue message

app.MapPost("/reports", async (
        StatementRequest request, ClaimsPrincipal user, ReportRequests reports, CancellationToken ct) =>
    {
        var requester = new Requester(
            user.FindFirstValue("sub") ?? throw new InvalidOperationException("Missing subject claim."),
            user.FindFirstValue("tid") ?? throw new InvalidOperationException("Missing tenant claim."));

        return (await reports.RequestAsync(requester, request, ct)).ToHttpResult();
    })
    .RequireAuthorization("reports.generate");    // an early rejection, not the decision
public sealed class ReportRequests(
    IAccountStore accounts,
    IAnalystDirectory directory,
    StatementPolicy policy,
    IReportJobStore jobs,
    TimeProvider clock)
{
    public async Task<RequestResult> RequestAsync(
        Requester requester, StatementRequest request, CancellationToken ct)
    {
        // Tenant-scoped, so another tenant's account looks like a missing one.
        AccountSnapshot? account = await accounts.FindAsync(requester.TenantId, request.AccountId, ct);
        if (account is null)
        {
            return RequestResult.NotFound;
        }

        AnalystFacts facts = await directory.GetAsync(requester.TenantId, requester.UserId, ct);

        StatementDecision decision = policy.Evaluate(facts, account);
        if (!decision.IsAllowed)
        {
            return RequestResult.Denied(decision.ReasonCode);
        }

        var job = ReportJob.Queue(requester, account.Id, request.Period);

        // The job, the decision, and an outbox row commit together. A publisher sends
        // ReportJobReady(job.Id) afterward, so a queue outage cannot lose an accepted job. It may
        // publish the same message twice, which is one reason the worker's claim is conditional.
        await jobs.AddWithOutboxAsync(job, decision, clock.GetUtcNow(), ct);

        return RequestResult.Accepted(job.Id);
    }
}

The policy is ordinary code with no I/O, and the API and the worker share its implementation. That keeps the request and the worker from drifting into two policies:

public sealed class StatementPolicy
{
    public StatementDecision Evaluate(AnalystFacts facts, AccountSnapshot account)
    {
        if (facts.TenantSuspended) return StatementDecision.Deny("tenant.suspended");
        if (!facts.IsActive) return StatementDecision.Deny("analyst.inactive");
        if (!facts.CanGenerateStatements) return StatementDecision.Deny("analyst.permission-missing");
        if (!facts.AssignedAccounts.Contains(account.Id)) return StatementDecision.Deny("analyst.not-assigned");
        if (account.LegalHold) return StatementDecision.Deny("account.legal-hold");

        return StatementDecision.Allow(new StatementScope(IncludeBankDetails: facts.CanViewBankDetails));
    }
}

Sharing an implementation does not make deployments atomic, so the API and the worker can run different versions during a rollout. When a rule tightens, finish rolling out the worker first, and drain or stop older consumers before relying on the new rule. A job the old API accepted is then rejected visibly, rather than an old worker running what the new rule forbids.

The worker authenticates as itself and starts from the job

No scope is created for a hosted service by default, which is useful here: one job gets one scope and one tenant.

public sealed class ReportWorker(IServiceScopeFactory scopes, IReportQueue queue) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (ReportJobReady message in queue.ReadAllAsync(stoppingToken))
        {
            // Acknowledgment and per-job error handling are omitted. Handle them in a real loop:
            // an unhandled exception from a BackgroundService stops the host by default.
            await using AsyncServiceScope scope = scopes.CreateAsyncScope();
            ReportJobRunner runner = scope.ServiceProvider.GetRequiredService<ReportJobRunner>();
            await runner.RunAsync(message.JobId, stoppingToken);
        }
    }
}
public sealed class ReportJobRunner(
    IReportJobStore jobs,
    ITenantContext tenant,
    IAccountStore accounts,
    IAnalystDirectory directory,
    StatementPolicy policy,
    IStatementBuilder builder,
    IReportStore reports,
    TimeProvider clock)
{
    public async Task RunAsync(Guid jobId, CancellationToken ct)
    {
        // Finding a job by ID is deliberately cross-tenant. Nothing below it is.
        ReportJob? job = await jobs.FindQueuedAsync(jobId, ct);
        if (job is null)
        {
            return;   // unknown, already claimed, or cancelled: nothing to do
        }

        // The tenant comes from the record the API wrote, never from the message.
        tenant.Set(job.TenantId);

        // If these reads throw, the job stays queued and is retried later. An unavailable
        // directory never becomes permission. This sketch's directory reports an unknown user as inactive.
        AccountSnapshot? account = await accounts.FindAsync(job.TenantId, job.AccountId, ct);
        AnalystFacts facts = await directory.GetAsync(job.TenantId, job.RequestedBy, ct);

        StatementDecision decision = account is null
            ? StatementDecision.Deny("account.not-found")
            : policy.Evaluate(facts, account);

        if (!decision.IsAllowed)
        {
            // The original decision stays on record. This is a new decision about a later moment.
            await jobs.TryRejectAsync(job.Id, job.Version, decision, clock.GetUtcNow(), ct);
            return;
        }

        // Queued to Running, at this version. A duplicate delivery or a cancellation loses here.
        if (!await jobs.TryClaimAsync(job.Id, job.Version, decision, clock.GetUtcNow(), ct))
        {
            return;
        }

        // The worker's access is the outer limit. The decision's scope is what this report may contain.
        Statement statement = await builder.BuildAsync(job.AccountId, job.Period, decision.Scope, ct);

        // Simplified: keyed by job. Recovery needs an attempt-specific key and a conditional publish; see below.
        ReportId report = await reports.PutAsync(job.Id, statement, ct);
        await jobs.CompleteAsync(job.Id, report, clock.GetUtcNow(), CancellationToken.None);
    }
}

The tenant filter needs a matching change. Replace the CurrentTenant service from the earlier anti-pattern with a context that the job sets explicitly, because "no tenant" should fail rather than widen:

// One instance per scope, so one job's tenant cannot reach another job.
services.AddScoped<ITenantContext, TenantContext>();

public sealed class TenantContext : ITenantContext
{
    private string? _tenantId;

    public string RequiredTenantId =>
        _tenantId ?? throw new InvalidOperationException("No tenant is set for this unit of work.");

    public void Set(string tenantId) => _tenantId = tenantId;
}

modelBuilder.Entity<Invoice>().HasQueryFilter(
    invoice => invoice.TenantId == _tenantContext.RequiredTenantId);

The runner and its tenant-filtered data access must resolve the same scoped instance, set before any tenant-scoped query runs: from the job record in the worker, and from the authenticated request in the API.

The rare job that really does span tenants, such as a platform-wide metrics rollup, should say so with an explicit IgnoreQueryFilters() that a reviewer can find. It should not rely on a value that happens to be null. That call disables every filter on the entity type, including any soft-deletion filter, so the job must reapply the other restrictions it still needs. From EF Core 10, named filters can be disabled individually.

Three things in this sketch are deliberately limited:

  • The guarantee has a stated edge. The decision is made before the claim, and the claim protects only the job's own state. An analyst removed between the check and the claim, or during a long build, still gets a finished statement in the store. For this feature that is acceptable, partly because the download is checked separately, as a later section shows. An operation with an irreversible effect may need to bring those facts into the claim itself, or check them at the point of effect. Where Should Authorization Live in a .NET Application? discusses making that guarantee explicit.
  • Crash recovery is omitted. A worker that dies after claiming leaves a job in Running. A production design needs a lease or a sweeper that returns it to the queue. Rebuilding a statement is cheap and repeatable, so this flow can try again, but recovery must also stop an expired attempt. A paused worker can resume after another worker has taken over the job, and the two attempts may hold different field scopes, so a shared report key alone would let the older attempt overwrite the newer result. Use attempt-specific output, such as reports/{jobId}/{attemptId}, with a conditional publish-and-complete step that succeeds only while this attempt still owns the job, or an equivalent ownership check. Your Retry Logic May Be Replaying an Authorization Decision covers leases, retries, and what changes when the effect cannot safely be repeated.
  • Application filtering is not containment. decision.Scope keeps an honest worker from over-sharing. It does not contain a compromised one, which can read everything its identity can reach.

So the worker's identity still has to be narrow on its own terms. Give it the data access its job type needs and nothing more: read access to billing tables, write access to the report store, no payment API, and no administrative roles. A separate identity per kind of job costs little and limits how far one flaw can travel. If tenants must be isolated from a compromised worker, that takes separately enforced identities or partitions, and Can One Worker Safely Execute Delayed Operations for Many Tenants? works through it.


What Changes While the Job Waits

Time is the reason a background job needs its own check. The useful question is not "did anything change?" but "which changes should stop this job?" For a statement that the analyst will read, these are reasonable answers:

What changed Effect on the waiting job Why
Analyst unassigned from the account Stop The output goes to someone no longer entitled to it
Analyst deactivated, or the permission removed Stop Same reason
Field-level permission removed Build the narrower statement Scope comes from the current decision, not the request-time one
Account placed under legal hold, or tenant suspended Stop Current state forbids the operation
Policy revised Evaluate under the current policy The worker uses the deployed policy; no version comparison is needed for this flow
Job cancelled Stop The conditional claim loses
New invoices posted to the account Usually nothing A statement reflects the data when it runs, unless the product promised a fixed snapshot
Analyst signed out, or the session or access token expired Nothing Closing a browser is not a revocation. The analyst asked for a report to read later
Worker's own credential rotated Nothing That is the platform's concern, not an authorization event

The last two rows show what the forwarded-token design gets wrong. Token lifetime stands in for a policy decision nobody made, stopping jobs that should run while allowing others that should not.

These answers depend on the operation. A statement is produced for the requester to read, so the requester's current access matters. A vendor payout scheduled by an operator who left the company on Wednesday may rightly still run on Friday, because the organization accepted that commitment. There, the requester's later status is a written policy choice, not a default. What matters is having an answer and a test for it, instead of inheriting one from a credential.

Whichever rule applies, keep the historical record intact. If the worker denies the job, the original decision still says the analyst was allowed on Monday, because that was true. The later decision says the job was refused on Thursday. Overwriting the first with the second loses the story.

Schedules outlive the person who created them

A recurring job makes the same question sharper. If an analyst sets up a monthly statement and leaves the company, each run has to answer it again. Choose one of these and write it down: the schedule belongs to its creator and pauses when they lose access; ownership transfers to a named person or a team that a re-check can evaluate; or the schedule is organizational work that an administrator approved, with recipients checked at delivery. An unowned schedule running under a service identity is how a report keeps going to someone years after anybody remembers why.


Reusing Your ASP.NET Core Authorization Handlers

If the rules already live in IAuthorizationHandler classes, you may want the worker to call them rather than maintaining a second copy. IAuthorizationService needs only a ClaimsPrincipal, a resource, and a policy, so it works outside a request, as long as the worker registers it with AddAuthorizationCore and the handlers do not read HttpContext. A worker can build a principal for the recorded requester and evaluate it:

// Rebuilt from current stores, for evaluation only. The worker does not become this user.
ClaimsPrincipal requester = await principals.CreateForEvaluationAsync(job.TenantId, job.RequestedBy, ct);

AuthorizationResult result = await authorization.AuthorizeAsync(requester, account, "GenerateStatement");

Three conditions make this safe:

  • The principal is rebuilt from current data, never from claims saved at request time. Saved claims would make the check a replay of the sign-in. If your application uses ASP.NET Core Identity, IUserClaimsPrincipalFactory<TUser> creates a principal from the stored user. With an external identity provider, only the facts you hold locally can be rebuilt. If a handler depends on a claim only the provider issues and the requirement must still hold at execution, obtain that fact from an authoritative source the worker can read, or defer the job. Leave it out of the worker's policy only when the operation explicitly accepts the recorded request-time evidence.
  • Creating the principal does not establish that the account is still eligible. The default factory builds claims from the stored user; it does not check lockout or your application's own rules. The evaluation path must still check current account status, tenant status, and any other execution-time restrictions, as StatementPolicy does.
  • Conditions about the original sign-in are not rebuilt. "Signed in with multi-factor authentication," "on a managed device," and "from an allowed network" describe the moment of the request. The worker cannot re-establish them, and a rebuilt principal should not pretend to. Where the operation's policy accepts that, evaluate them when the job is accepted, record that they passed, and leave them out of the worker's policy. If execution requires fresh authentication, defer the job for renewed user action instead.

The third condition is a useful way to sort a policy in general. Some requirements are about how the actor arrived, and only the actor can establish them again. Others are about what is true of the actor and the resource, and the worker can check them again later. Splitting a policy along that line makes the re-check possible without weakening either part.


Which Actor Belongs in the Evidence

Both, in different roles. Each single-actor answer tells a false story:

  • GeneratedBy = svc-report-worker erases the person who asked. It is the failure from the opening example, and it leaves no way to answer "who requested this?"
  • GeneratedBy = analyst-17 claims that the analyst read billing data at 02:10, when they were asleep. A worker that impersonates the user produces this record automatically, and an investigator has no way to separate the analyst's direct activity from the system's.

Record the requester and the executor as different fields, on the events each one is responsible for:

Requested   J-8841   by analyst-17 (tenant-north)   Mon 17:42
                     statement A-204 Q3   decision allowed, policy rev 12
Started     J-8841   by svc-report-worker           Thu 02:10
                     for analyst-17   decision allowed, policy rev 13, no bank details
Completed   J-8841   by svc-report-worker           Thu 02:14   report R-5521
Downloaded  R-5521   by analyst-17                  Thu 08:31

This does not need a specialized store. For most applications, the job table's columns plus structured logs are enough. Your Audit Log Records the Story, Not the Decision covers when more is justified.

If the worker calls another service, passing the requester along is useful context, and the receiving service should treat it as what it is. A header such as X-Requested-By: analyst-17 proves only that an authenticated worker said so. That can be acceptable when the receiver explicitly trusts this worker to make that statement. Authorization decisions based on it are only as strong as that trust.


When the Downstream Service Must See the User

Sometimes the worker's own identity is the wrong tool. If the report pulls documents from a service that enforces the user's permissions, such as a user's own files in a third-party system, then the service should evaluate that user, and a broad application permission would be the over-privileged option. This is a legitimate reason for delegated access. It still does not mean putting the token in the message.

Use a delegation mechanism that keeps both identities and keeps the secret out of the queue:

  • OAuth 2.0 Token Exchange (RFC 8693) lets an authorization server issue a new token for a specific target. It distinguishes delegation, where the acting party keeps its own identity, from impersonation, where it is indistinguishable from the user. A delegation token can express this directly, with sub naming the user and the act claim naming the acting party, which preserves the two-actor record this article asks for.
  • Microsoft Entra's On-Behalf-Of flow obtains a separate token for the downstream API, using a token whose audience is your API. For work that outlives the request, MSAL.NET's long-running OBO support starts the process in the API with InitiateLongRunningProcessInWebApi, and later code calls AcquireTokenInLongRunningProcess with a session key, using the same application and a shared distributed token cache. The tokens stay in that server-side cache. The job record needs to hold only the session key, and the queue message still carries only the job identifier.

Be clear about what that buys. The secret is no longer in the queue, and the downstream service sees the user. But MSAL's documentation notes that these APIs store a refresh token, which has a long lifetime and can be used repeatedly, so the cache is now a store of standing user access. It needs real protection, and you should end the session when the job finishes or is cancelled, with StopLongRunningProcessInWebApiAsync in MSAL.NET 4.51.0 or later. The delegated token also describes the user's scopes, not this one report, so it does not make your own pre-work check unnecessary. And the job needs somewhere to go when the provider requires the user to sign in again, for example a NeedsUserAction state, instead of failing silently or retrying forever.

Use delegation only for the calls that need it. A worker can read your own billing database under its own identity and call the one user-permissioned service with a delegated token.


The Finished Report Is a New Resource

The statement in the report store has its own lifetime, and generating it is not the last decision. The analyst may lose access between generation and download, and a build that started before their removal may finish after it.

So the download endpoint should authorize the current request against the job's account, the same way any other endpoint would, and also against what the file actually contains. Current account access alone does not cover every field in an older report. An analyst who could view bank details when the PDF was built, lost that permission last week, and is still assigned to the account would pass an account-only check and receive those details. Record the scope each report was built with, compare it with the downloader's current permissions, and deny the download or regenerate a narrower report if the file contains anything the downloader may not currently view. A report narrower than the downloader's current permissions is still safe to release. Regenerating is a new operation with its own decision and record, not a silent rewrite of the old file.

Do not treat "this report was generated for you" as a permanent entitlement, and do not email a link that works for anyone who holds it indefinitely. A short-lived storage URL is reasonable when it is issued after that check and for that download. Set a retention period too: a finished statement is a copy of sensitive data that the source system's later permission changes will not reach. A later legal hold does not reach it automatically either, so the hold policy should say whether stored reports are retained or blocked, and retention cleanup must respect that answer.

If the report is delivered instead of pulled, for example emailed as an attachment, the delivery step is the last point of control, and it should perform the same check, including the report's contents, against the recipients. Once sent, it cannot be recalled by any of the checks above.


Proportion: From the Request to a Separate Boundary

The right design depends on what the job crosses, not on whether the word "background" applies. Most jobs should stop at the first row that fits:

Shape What actually crosses What to carry What to check
Do the work in the request Nothing Nothing Ordinary authorization, in the request
In-process queue, such as a Channel<T> with a BackgroundService, for work that runs within moments The end of the request, with the same process and trust boundary The requester's ID, tenant, and resource IDs as plain values. Never HttpContext or a ClaimsPrincipal from the request The request-time decision is often enough when the delay is negligible. Re-check if it might not be. An in-memory queue loses its contents when the process exits, so persist anything that matters
Durable queue, worker in the same trust boundary Time, and a process A job identifier Re-evaluate from the job record and current stores, as this article's flow does
System-owned work, such as a nightly reconciliation Nothing from a user: no user decision to preserve Nothing user-related Service authorization for that job type, under the worker's own identity
Downstream enforces the user's permissions Delegated access for specific calls A job identifier. The job record references server-side delegated state Re-evaluate as above, and let the downstream enforce its own permissions
Worker or executor across a trust boundary, or not trusted with standing access Authority itself A job identifier and an operation-specific grant The protected host validates the grant and current state, and consumes it atomically

When a stored job identifier plus current authorization is enough

This is the common case, and it needs no token design. It is enough when all of these hold:

  • The worker runs in the same trust boundary as the data, and its standing access for this job type is acceptable to your threat model.
  • The worker can read every fact the policy needs from authoritative stores.
  • The rule can be evaluated fresh. It does not depend on preserving one earlier decision exactly, or the part that does has been recorded.
  • Only trusted components can create job rows, and a test proves that a request cannot create one without a decision.
  • The effect is modest enough, or checked enough at its destination, that a compromised worker's reach is an accepted risk.

When it is not enough

A separately issued, operation-specific grant, often called a capability, narrows authority to one operation on one resource, for one executor at one host, for a limited time and number of uses. It is worth its cost when at least one of these is true:

  • The worker should not hold standing access to every operation of this kind. A worker that can send any payout is a high-value target; one that can send only payouts it holds unexpired, unused grants for is a smaller one.
  • The executor sits in another service or trust boundary and cannot reconstruct the original decision on its own.
  • A specific earlier decision must be preserved exactly, such as a consent or an approval, and the worker must not broaden it.
  • The system needs the host that owns the effect, rather than a worker it does not fully trust, to enforce that decision.

How Short-Lived Execution Authority Differs from User Authorization explains that model and when to skip it, and Capability-Scoped Background Operation follows this same report-generation scenario end to end with those controls in place.


Testing the Boundary

These tests prove negatives through the real worker and download paths: no credential crossed, no report appeared, and a forbidden download was refused. They are specifications of the behavior described above, not evidence of a working implementation, and each demonstrates behavior only under the conditions it sets up.

[Fact]
public async Task Queue_message_contains_only_the_job_identifier()
{
    var harness = ReportHarness.Create();
    harness.Directory.AssignAnalyst("analyst-17", "tenant-north", "A-204");

    await harness.PostReportRequestAsync(user: "analyst-17", tenant: "tenant-north", account: "A-204");

    JsonElement message = harness.Queue.PublishedMessages.Single();
    Assert.Equal("jobId", Assert.Single(message.EnumerateObject()).Name);
}

[Fact]
public async Task Analyst_removed_before_the_job_runs_gets_no_report()
{
    var harness = ReportHarness.Create();
    harness.Directory.AssignAnalyst("analyst-17", "tenant-north", "A-204");
    Guid job = await harness.PostReportRequestAsync(user: "analyst-17", tenant: "tenant-north", account: "A-204");

    harness.Directory.UnassignAnalyst("analyst-17", "A-204");
    await harness.RunWorkerAsync();

    Assert.Empty(harness.Reports.Stored);
    Assert.Equal(ReportJobState.Rejected, await harness.StateAsync(job));
    Assert.Equal(DecisionOutcome.Allowed, (await harness.DecisionsAsync(job))[0].Outcome);    // Monday stays true
    Assert.Equal("analyst.not-assigned", (await harness.DecisionsAsync(job))[^1].ReasonCode);
}

[Fact]
public async Task Report_contains_only_what_the_requester_may_see()
{
    var harness = ReportHarness.Create();
    harness.Directory.AssignAnalyst("analyst-17", "tenant-north", "A-204", canViewBankDetails: false);
    await harness.PostReportRequestAsync(user: "analyst-17", tenant: "tenant-north", account: "A-204");

    await harness.RunWorkerAsync();    // the worker's own data access can read bank details

    Assert.False(harness.Reports.Stored.Single().ContainsBankDetails);
}

[Fact]
public async Task Report_with_bank_details_is_refused_after_that_permission_is_removed()
{
    var harness = ReportHarness.Create();
    harness.Directory.AssignAnalyst("analyst-17", "tenant-north", "A-204", canViewBankDetails: true);
    await harness.PostReportRequestAsync(user: "analyst-17", tenant: "tenant-north", account: "A-204");
    await harness.RunWorkerAsync();
    ReportId report = harness.Reports.Stored.Single().Id;

    harness.Directory.RemoveBankDetailsPermission("analyst-17");    // still assigned to the account
    HttpResponseMessage response = await harness.DownloadAsync(report, user: "analyst-17", tenant: "tenant-north");

    Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}

[Fact]
public async Task Expired_user_session_does_not_stop_an_allowed_job()
{
    var harness = ReportHarness.Create();
    harness.Directory.AssignAnalyst("analyst-17", "tenant-north", "A-204");
    await harness.PostReportRequestAsync(user: "analyst-17", tenant: "tenant-north", account: "A-204");

    harness.Identity.ExpireAllSessionsFor("analyst-17");
    await harness.RunWorkerAsync();

    Assert.Single(harness.Reports.Stored);
}

[Fact]
public async Task Unavailable_directory_leaves_the_job_queued_and_builds_nothing()
{
    var harness = ReportHarness.Create();
    harness.Directory.AssignAnalyst("analyst-17", "tenant-north", "A-204");
    Guid job = await harness.PostReportRequestAsync(user: "analyst-17", tenant: "tenant-north", account: "A-204");

    harness.Directory.BecomeUnavailable();
    await harness.RunWorkerAsync();

    Assert.Empty(harness.Reports.Stored);
    Assert.Equal(ReportJobState.Queued, await harness.StateAsync(job));
}

Also cover:

  • A forged message. An identifier for no existing job does nothing. So does one for a job that is already complete or cancelled.
  • Duplicate delivery. The same message delivered twice, including concurrently, produces one claim and one stored statement.
  • No tenant set. A query through the filtered context in a worker scope with no tenant set throws. It does not return every tenant's rows.
  • Cross-tenant job data. A job row whose recorded account belongs to another tenant finds no account and stores nothing, which tests the tenant-scoped lookup independently of the API.
  • The evidence. A completed job records the requester and the worker as separate fields, and the worker's reads are not logged as the analyst's activity.
  • Tenant suspension. A tenant suspended while the job waits produces a rejected job and no stored statement.
  • The download. An analyst unassigned after generation is refused at the download endpoint. A masked report remains downloadable after the analyst gains the bank-details permission, because it contains nothing they may not view.

An architecture test can also fail the build if a type in the worker project takes a dependency on IHttpContextAccessor, or if a message contract gains a property beyond the identifier. How to Test That a Denied Operation Never Executes develops the technique of asserting that an effect never happened.


Failure Modes

1. The access token in the message. The job gets everything the token allows, stored wherever messages are stored, and whether it can run depends on when the token expires.

2. Refresh tokens in job data, or kept "just for the worker" with no design. A long-lived way to obtain user access is now one more secret in job storage, usually with no owner and no end date.

3. Copied claims treated as facts. The worker reads Roles or TenantId from a message. Those values are a snapshot that may be stale, with no proof of where they came from.

4. Null means everything. A filter or "current user" service treats a missing HttpContext as the system, and a worker quietly reads across every tenant.

5. The worker's identity as the only check. The job runs because the queue delivered it and the worker could reach the data. Nobody asked whether this requester may have this output.

6. Output scoped by the worker's reach. The report contains what the worker can read rather than what the requester may see, so it discloses more than the screen beside it.

7. A single actor in the record. Either the requester disappears behind the service account, or the service's actions are logged as if the person performed them.

8. Impersonation used for convenience. The worker acts as the user everywhere because one downstream call needed it, which loses the two-actor record and widens every call.

9. A second, drifting policy in the worker. The worker has its own simplified check, and the API and worker slowly disagree about who is allowed.

10. A report released on account access alone. Generation was carefully checked, and the output is then available to anyone holding a URL, or to anyone with current account access regardless of which fields the file contains.

11. A custom token for every job. Infrastructure for issuing, consuming, and revoking grants protects a job that could have read its own row and asked the same policy. It adds failure modes and does not narrow anything the worker can already reach.


A Short Review Checklist

The boundary

  1. What actually crosses here: nothing, the end of a request, time and a process, or a trust boundary?
  2. Does the queue message contain anything beyond a way to find the accepted operation?
  3. Is there no access token, refresh token, service credential, or copied claim set in messages, job arguments, or job storage?
  4. Is the job record committed before, or atomically with, publication?
  5. Are the components that can create job rows limited, and can they do so only after a decision?

The worker

  1. Does the worker authenticate with its own workload identity, obtained from its platform?
  2. Is that identity limited to what this kind of job needs, independently of any application check?
  3. Does each job get its own scope, with its tenant taken from the job record?
  4. Does a missing tenant or user fail the operation rather than widen it?
  5. Does a lease or sweeper recover a job stranded in Running, and is an expired attempt prevented from publishing or completing it?

The decision

  1. Is the same policy implementation used at the request and in the worker, evaluated against current authoritative facts?
  2. Has the team written down what should happen for each change: requester removed, resource state, tenant, policy, cancellation?
  3. Is each sign-in condition, such as multi-factor authentication, either accepted at the request by policy or deferred for renewed user action, and never rebuilt by the worker?
  4. Does an unavailable dependency leave the job waiting, rather than proceeding?
  5. Is the output limited by the decision's scope rather than by what the worker can read?

Afterward

  1. Do records show both the requester and the executor, in separate fields?
  2. Is the finished output authorized at download or delivery against its actual contents, and does it have a retention period?
  3. Does each recurring job have a defined owner, and a rule for when that owner loses access?

Proportion

  1. If delegated access is used, is it limited to the calls that need it, held server-side, and ended when the job finishes or is cancelled?
  2. If a capability is used, can you name which of its conditions justifies it?

Continue Deeper

To understand why a user's authorization and an executor's authority are different things, and what a narrow grant looks like when you do need one, read How Short-Lived Execution Authority Differs from User Authorization. Do You Need a Capability Token, or Are Roles and Claims Enough? helps if you are still deciding whether that infrastructure is justified.

For the same report-generation scenario designed end to end, with portable authority, atomic claims, drift, ambiguous execution, and requester-facing status, follow Capability-Scoped Background Operation. Scoped Capability and Host-Owned Execution teaches that lifecycle step by step.

If one worker serves many tenants, Shared Multi-Tenant Worker and Delayed Execution Isolation separates stopping cross-tenant substitution from containing a compromised worker.

If your job can fail and run again, Your Retry Logic May Be Replaying an Authorization Decision covers what each retry layer can and cannot know, and Should Authorization Fail Open, Fail Closed, or Defer? covers what to do when the worker cannot reach the facts it needs.


The Short Answer

Do not put the user's token, refresh token, or claims in the job, and do not let the worker's service identity stand in for permission. They answer different questions. The requester's identity says who asked. The worker's identity says which process is running and sets the outer limit of what it can reach. Neither one says that this operation is allowed now.

Record the accepted operation when the request is authorized, put only its identifier on the queue, and let the worker authenticate as itself. Before it starts, have it evaluate the same policy against current facts for the recorded requester, limit the output to what that decision allows, and record both identities. Check the finished output again, against what it actually contains, when someone comes to collect it.

For many jobs, that is the whole design: a row, an identifier, and an ordinary check. Add delegated access when a downstream service must evaluate the user itself, and add a narrow grant when the worker should not hold standing access or the executor sits across a trust boundary. Do not add either merely because a queue exists.