Resource-Based Authorization in ASP.NET Core
Use resource-based authorization in ASP.NET Core to stop IDOR: check ownership, enforce tenant boundaries, and test access before returning private data.
The invoice endpoint requires a valid login. Its integration test signs in, requests an invoice, and gets a successful response. Then someone changes the invoice identifier in the URL and receives another customer's invoice. Authentication worked perfectly. The application answered the wrong question.
Authentication establishes who is calling. Authorization decides whether that caller may perform this operation on this particular resource. A valid identity does not imply ownership of every record reachable through an authenticated endpoint.
Resource-based authorization in ASP.NET Core makes the resource part of that decision. Instead
of asking only whether a user has the Customer role, the handler can inspect the invoice's owner
and tenant before permitting access. The important change is the boundary, not the framework API.
A valid login reaches the same endpoint
Caller: subject Alice, tenant North
Own invoice
Owner: Alice
Tenant: North
Tenant matches + owner matches
Read allowed
Another customer's invoice
Owner: Bob
Tenant: North
Tenant matches, owner differs
Read denied
Another tenant's invoice
Owner: Alice
Tenant: South
Owner matches, tenant differs
Read denied
An identifier tells the server which record you want. It does not prove that the record belongs to you.
Consider this intentionally incomplete endpoint:
app.MapGet("/invoices/{id:guid}", async (
Guid id, BillingDb db, CancellationToken cancellationToken) =>
{
var invoice = await db.Invoices.FindAsync([id], cancellationToken);
return invoice is null ? Results.NotFound() : Results.Ok(invoice);
}).RequireAuthorization();The endpoint checks authentication, then uses the supplied identifier without an ownership check. Changing an integer identifier to a GUID makes guessing less convenient but does not establish an access rule. Identifiers also appear in links, support conversations, exports, and application logs.
This failure is commonly called insecure direct object reference, or IDOR. OWASP's API terminology is broken object level authorization, or BOLA. Its guidance explicitly covers identifiers of different types and requires checking the requested action against the requested object. OWASP: Broken Object Level Authorization
A role check can still be useful. An auditor may read invoices while a customer can read only their
own. But the role is one input to a policy. An endpoint-level role attribute cannot inspect a row
that the application has not loaded yet. ASP.NET Core supports passing that loaded resource to
IAuthorizationService.AuthorizeAsync for an imperative authorization decision.
Microsoft: Resource-based authorization
Use a deliberately narrow example: a customer may read an invoice only when the customer and invoice belong to the same tenant, and the invoice's owner subject matches the customer's subject. There is no administrator exception in this example. Adding one later requires an explicit rule and its own tests.
Assume the authentication layer validates tokens and maps the identity provider's stable subject
to ClaimTypes.NameIdentifier. It also supplies a trusted tenant_id claim. If your provider keeps
the subject under sub, adapt the lookup to your configured mapping. Do not copy either value
from request JSON, query strings, or an unvalidated token.
public sealed record InvoiceAccess(string TenantId, string OwnerSubject);
public sealed class ReadInvoiceRequirement : IAuthorizationRequirement { }
public sealed class ReadInvoiceHandler
: AuthorizationHandler<ReadInvoiceRequirement, InvoiceAccess>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ReadInvoiceRequirement requirement,
InvoiceAccess invoice)
{
var subject = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var tenant = context.User.FindFirst("tenant_id")?.Value;
if (context.User.Identity?.IsAuthenticated == true
&& !string.IsNullOrEmpty(subject)
&& !string.IsNullOrEmpty(tenant)
&& subject == invoice.OwnerSubject
&& tenant == invoice.TenantId)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}The absence of Succeed leaves this requirement unsatisfied. Missing claims therefore deny access.
Avoid a fallback such as treating a missing tenant as the default tenant. That would turn an
identity integration mistake into cross-tenant access.
The subject comparison assumes one trusted issuer namespace. If multiple issuers can produce the same subject string, persist and compare the issuer as well. An email address is a poor replacement: it can change, and your identity system may not treat it as a stable unique identifier.
Register the stateless handler and a named policy. The example handler performs no database calls and can be a singleton. A handler that depends on scoped services needs a suitable lifetime. The application is assumed to have its authentication scheme, middleware, and database configured; these excerpts show the resource check rather than a complete application setup.
builder.Services.AddSingleton<IAuthorizationHandler, ReadInvoiceHandler>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ReadInvoice", policy =>
policy.AddRequirements(new ReadInvoiceRequirement()));
});
app.MapGet("/invoices/{id:guid}", async (
Guid id, ClaimsPrincipal user, BillingDb db,
IAuthorizationService authorization, CancellationToken cancellationToken) =>
{
var tenant = user.FindFirst("tenant_id")?.Value;
if (string.IsNullOrEmpty(tenant)) return Results.Forbid();
var invoice = await db.Invoices.AsNoTracking()
.SingleOrDefaultAsync(x => x.Id == id && x.TenantId == tenant,
cancellationToken);
if (invoice is null) return Results.NotFound();
var access = new InvoiceAccess(invoice.TenantId, invoice.OwnerSubject);
var decision = await authorization.AuthorizeAsync(user, access, "ReadInvoice");
if (!decision.Succeeded) return Results.NotFound();
return Results.Ok(new { invoice.Id, invoice.Total, invoice.Currency });
}).RequireAuthorization();The tenant predicate narrows the database lookup. The handler then checks ownership for the read operation. Both conditions belong to the rule: two customers can share a tenant without sharing invoices, and one subject can participate in multiple tenants.
Returning 404 for an inaccessible invoice avoids deliberately confirming that an identifier
exists. Some applications choose 403 where revealing existence is acceptable. Choose the contract
deliberately and apply it consistently; the authorization check must happen either way.
The response projects a small public representation instead of serializing the entire entity. Resource access and field access are separate questions. Being allowed to read an invoice does not automatically grant access to internal fraud notes or payment-provider tokens.
A secure detail endpoint does not protect a download endpoint that fetches the same invoice by ID without authorization. List results, attachments, exports, and batch actions each need the same boundary. A batch request containing ten identifiers must not become authorized merely because the caller owns the first one.
For nested routes, check the relationship as well. Loading /projects/A/invoices/B requires proving
that invoice B belongs to project A. Authorizing access to A and then loading B globally leaves a
second identifier-shaped hole.
Updates add another concern: state can change between reading a resource and writing it. If ownership can be transferred, an update should include the relevant ownership and concurrency conditions in its write predicate, or use an appropriate transaction strategy. Treat a zero-row update as a failed precondition, not a successful edit. Authorization of an earlier snapshot is not permission to overwrite any later state.
Keep trusted ownership fields out of writable request models. Otherwise a customer may try to change the ownership data that a future authorization check relies on. The mass assignment example covers that adjacent input boundary.
The most valuable negative test uses a fully authenticated caller. An anonymous request proves that the login gate exists; it does not prove that the object boundary works.
| Caller | Requested invoice | Expected result in this example |
|---|---|---|
| Owner in the matching tenant | Own invoice | Successful response |
| Different subject in the same tenant | Someone else's invoice | Not found |
| Same subject in another tenant | Invoice in the original tenant | Not found |
| Identity without a tenant claim | Any invoice | Forbidden |
| Authenticated owner | Missing identifier | Not found |
Seed these relationships explicitly in an integration test. Assert that rejected responses contain no invoice data, and that rejected writes leave stored state unchanged. Test the real endpoint pipeline as well as the handler: a perfect handler provides no protection when an endpoint forgets to call it.
Also test the legitimate exception when you introduce one. If a support role can read across customers, specify which tenants it covers and whether access needs a current support assignment. Avoid turning a convenient role name into unrestricted global access by accident.
Claims can become stale after membership is revoked. Decide whether sensitive operations need a current membership lookup, short-lived credentials, or another revocation mechanism. That decision depends on how quickly your product promises to remove access, not on whether the token signature is still valid.
The useful habit is to write the ownership rule next to the successful behavior, then try the same operation with a different valid identity. A fix that blocks everyone is easy; a fix that preserves the owner's workflow while rejecting another customer demonstrates the boundary.
Katabench's Secure Coding track uses functional and adversarial checks to train that habit. The Tenant-Scoped Authorization puzzle specifically asks you to close a cross-tenant hole that ownership and role checks alone miss. The grading guide explains how the result distinguishes preserved behavior from blocked attacks. Use that feedback style in your own authorization tests: make the permitted operation concrete, then vary the caller, tenant, resource, and relationship one at a time.
Practice what you just read
- Open the Pro kata: Tenant-Scoped Authorization
Secure coding Medium Katabench Pro
Tenant-Scoped Authorization
Close a cross-tenant authorization hole that ownership and role checks alone cannot see.
More like this: C# secure coding exercises →