CWE-862: Missing Authorization - C
Overview
In ASP.NET Core, Missing Authorization usually takes one of two shapes. A controller action or Minimal API endpoint carries only a bare [Authorize] attribute, which confirms the caller is logged in while no Roles, Policy, or resource-based check restricts what they can do. Or a new endpoint is added with no [Authorize] attribute at all while sibling endpoints are protected. Minimal APIs are a common gap because [Authorize] on a controller class does not extend to a separately registered app.MapGet/app.MapPost route. The fix is policy-based authorization for role/claim checks, plus IAuthorizationService.AuthorizeAsync for resource-based checks where the decision depends on the specific entity being accessed.
Common Vulnerable Patterns
Bare [Authorize] With No Role or Policy
// VULNERABLE - confirms the caller is logged in, but not what they're allowed to do
[Authorize]
[HttpPost("orders/{id}/refund")]
public async Task<IActionResult> RefundOrder(int id)
{
await _orders.RefundAsync(id); // any authenticated user can call this
return NoContent();
}
// Attack: any authenticated user, regardless of role, calls
// POST /orders/500/refund directly
// Result: the refund executes with no role or permission check
Why this is vulnerable: [Authorize] with no Roles or Policy argument only requires ASP.NET Core's authentication middleware to have produced an authenticated ClaimsPrincipal - it does not check any claim, role, or permission on that principal.
Minimal API Endpoint With No Authorization At All
// VULNERABLE - Minimal API route registered with no authorization requirement
app.MapPost("/orders/{id}/cancel", (int id, IOrderService orders) =>
{
orders.Cancel(id);
return Results.NoContent();
});
// Attack: an anonymous or low-privilege caller sends POST /orders/500/cancel
// Result: no authentication or authorization check runs on this route at all
Why this is vulnerable: [Authorize] attributes on a controller class have no effect on Minimal API endpoints registered separately via app.MapPost/app.MapGet - each Minimal API route needs its own .RequireAuthorization(...) call, and this one has none.
Secure Patterns
Policy-Based Role/Claim Authorization
// SECURE - Program.cs - register a named policy
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanManageOrders", policy =>
policy.RequireRole("Admin", "OrderManager"));
});
// SECURE - controller action requires the named policy, not just authentication
[Authorize(Policy = "CanManageOrders")]
[HttpPost("orders/{id}/refund")]
public async Task<IActionResult> RefundOrder(int id)
{
await _orders.RefundAsync(id);
return NoContent();
}
// SECURE - Minimal API endpoint requires the same policy explicitly
app.MapPost("/orders/{id}/cancel", (int id, IOrderService orders) =>
{
orders.Cancel(id);
return Results.NoContent();
}).RequireAuthorization("CanManageOrders");
Why this works: The policy is defined once in AddAuthorization and referenced by name, so the same rule is enforced consistently whether the endpoint is an MVC controller action or a Minimal API route - each one opts in explicitly rather than inheriting protection implicitly from its class.
Resource-Based Ownership Check
// SECURE - a requirement/handler pair loads the resource and compares ownership
public class OrderOwnerRequirement : IAuthorizationRequirement { }
public class OrderOwnerHandler : AuthorizationHandler<OrderOwnerRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OrderOwnerRequirement requirement,
Order resource)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is not null && resource.OwnerId == userId)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
// SECURE - register the handler
builder.Services.AddScoped<IAuthorizationHandler, OrderOwnerHandler>();
[Authorize]
public class OrdersController : ControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IOrderRepository _orders;
public OrdersController(IAuthorizationService authorizationService, IOrderRepository orders)
{
_authorizationService = authorizationService;
_orders = orders;
}
[HttpGet("orders/{id}")]
public async Task<IActionResult> GetOrder(int id)
{
var order = await _orders.FindAsync(id);
if (order is null)
{
return NotFound();
}
var result = await _authorizationService.AuthorizeAsync(User, order, new OrderOwnerRequirement());
if (!result.Succeeded)
{
// Same answer as a missing record - see below
return NotFound();
}
return Ok(order);
}
}
Why this works: IAuthorizationService.AuthorizeAsync receives the actual loaded Order entity, so OrderOwnerHandler compares the specific record's owner claim to the caller's identifier rather than checking a role in isolation. An attacker who holds a valid session but requests someone else's order ID fails the handler regardless of any role they hold.
Both denials leave by the same door. Resource-based authorization has to load the record before it can judge it, so the handler necessarily knows the difference between "no such order" and "not your order" - and returning NotFound() for the first and Forbid() for the second hands that difference straight to the caller. 403 then confirms an ID is real and 404 confirms it is not, which is an existence oracle an attacker walks the ID space with. Answering 404 in both cases costs the legitimate caller nothing, because a user who cannot see a record gains nothing from learning it exists.
That applies to endpoints where the caller names a resource. A policy check that does not depend on one - [Authorize(Policy = "CanManageOrders")] on an endpoint the caller has no business reaching at all - should still answer 403, because refusing it discloses nothing about what exists.
Deny-by-Default Fallback Policy
// SECURE - any endpoint without an explicit [Authorize]/RequireAuthorization
// still requires authentication by default
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
Why this works: FallbackPolicy applies to any endpoint that has no authorization metadata attached at all, closing the gap where a newly added controller action or Minimal API route is left unprotected. It is a safety net for missing authentication, not a substitute for the role/policy or resource-based checks above - a fallback of RequireAuthenticatedUser() still lets any logged-in user through, so critical actions still need their own explicit Policy.
Framework-Specific Guidance
Minimal API Route Groups
// SECURE - apply the policy once to a route group instead of per-endpoint
var admin = app.MapGroup("/orders").RequireAuthorization("CanManageOrders");
admin.MapPost("/{id}/refund", (int id, IOrderService orders) => orders.Refund(id));
admin.MapPost("/{id}/cancel", (int id, IOrderService orders) => orders.Cancel(id));
Why this works: MapGroup lets every route added to the group inherit the same RequireAuthorization call, so a new endpoint added to the group is protected automatically instead of depending on each route remembering to call .RequireAuthorization for itself.
Razor Pages and Blazor
For Razor Pages, apply options.Conventions.AuthorizeFolder("/Admin", "CanManageOrders") in AddRazorPages rather than relying on per-page [Authorize] attributes being added consistently. For Blazor Server/WebAssembly, wrap restricted components in <AuthorizeView Policy="CanManageOrders"> for UI-level hiding, but still enforce the same policy server-side on any API the component calls - AuthorizeView alone is a UI convenience, not a security boundary.
Testing
- Normal: call the endpoint as a user holding the correct policy/role and owning the target resource; confirm success.
- Boundary: request a resource owned by a different user, then request an ID that does not exist, and confirm the two responses are identical - same status and same body. Under the resource-based pattern above both are 404. A 403 for one and a 404 for the other is an existence oracle whichever way round they are.
- Malicious: call a Minimal API endpoint directly with an HTTP client, bypassing any UI, as an authenticated user with no assigned role; confirm 403, not 200.
- Use
WebApplicationFactory<TProgram>with a test authentication handler to simulate different roles and claims in integration tests. - Re-run any SAST/DAST scan that reported the finding to confirm it no longer triggers.
Common Pitfalls
- Custom middleware that reads
HttpContext.UserbeforeUseAuthentication()has run: The pipeline order isUseRouting,UseAuthentication,UseAuthorization, then endpoints.[Authorize]itself fails loudly when that order is wrong, but custom middleware placed too early sees an unauthenticated principal with no claims and no exception - so a check written as "deny if the user holds a blocking claim" silently passes every request. - Assuming controller-level
[Authorize]covers Minimal API routes:[Authorize]on an MVC controller class has no effect onapp.MapGet/app.MapPostendpoints registered elsewhere - each Minimal API route needs its own.RequireAuthorization(...). - Treating
FallbackPolicyas sufficient:RequireAuthenticatedUser()as a fallback only guarantees login, not the right role or ownership - critical actions still need an explicitPolicyor resource-based check. - Role check with no ownership comparison: Using
[Authorize(Roles = "Customer")]on an endpoint that returns a record by ID, without verifying the record belongs to the caller - any customer can then read any other customer's record. NotFound()for a missing record andForbid()for someone else's: The pair is an existence oracle -403tells the caller the ID is real. On an endpoint that takes a resource ID, both denials need the same status and the same body.- Hiding UI elements with
AuthorizeViewinstead of enforcing the policy server-side:<AuthorizeView>in Blazor only controls what renders; the API the component calls still needs its own[Authorize(Policy = "...")].