CWE-285: Improper Authorization - C# / ASP.NET Core
Overview
In ASP.NET Core applications, improper authorization occurs when authentication/authorization middleware is configured but controllers, actions, Razor Pages, or minimal API endpoints lack effective authorization metadata. Missing, overly broad, or overridden checks expose protected resources to unauthenticated or under-authorized callers. The same [Authorize]/[AllowAnonymous] attribute model applies uniformly to Web API controllers, MVC controllers, and Razor Pages PageModel classes; minimal APIs use an equivalent fluent API (RequireAuthorization(), AllowAnonymous()).
Detection Context
This guidance applies when a security scanner detects:
UseAuthentication()andUseAuthorization()configured inProgram.csorStartup.cs- Controllers, actions, Razor Pages, or endpoints missing effective
[Authorize],RequireAuthorization(), or fallback-policy coverage - Protected endpoints accidentally marked with
[AllowAnonymous] - Applies to ASP.NET Core Web and Web API projects
Primary Defence: Apply [Authorize] at the controller class level, or configure a fallback policy, so endpoints are secured by default. Add method-level [Authorize(Roles = "...")] or [Authorize(Policy = "...")] on privileged operations, and mark genuinely public endpoints with [AllowAnonymous] so the exception is explicit. Enable both the UseAuthentication() and UseAuthorization() middleware, in that order.
Attribute Precedence Rules
Critical understanding:
- When both
[Authorize]and[AllowAnonymous]apply to an endpoint,[AllowAnonymous]takes precedence, at any level (class or method) - which is how authorization checks get disabled by accident. - A method-level
[Authorize]does not re-secure an action when[AllowAnonymous]still applies from the class or endpoint metadata.
Common Vulnerable Patterns
Missing Authorization Attributes on Controllers
// VULNERABLE - No authorization attribute
[ApiController]
[Route("api/[controller]")]
public class UserController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
// Anyone can access this, even unauthenticated!
var user = _userService.GetUser(id);
return Ok(user);
}
[HttpDelete("{id}")]
public IActionResult DeleteUser(int id)
{
// Critical operation accessible to anyone!
_userService.DeleteUser(id);
return NoContent();
}
}
Why this is vulnerable: Nothing in this controller requires authentication, so both the read and the delete action are reachable by anonymous callers even though UseAuthentication()/UseAuthorization() are configured in Program.cs.
Conflicting Attributes (AllowAnonymous Wins)
// VULNERABLE - AllowAnonymous takes precedence over Authorize
[Authorize]
[AllowAnonymous] // This wins - NO auth required!
public class DangerousController : ControllerBase
{
[HttpGet("admin/delete")]
public IActionResult DeleteAll()
{
// Still accessible without authentication!
_service.DeleteAllData();
return NoContent();
}
}
Why this is vulnerable: Stacking [Authorize] and [AllowAnonymous] on the same class does not "require auth except where overridden" - [AllowAnonymous] unconditionally wins, so the entire controller becomes anonymous.
Missing Role Checks on Privileged Operations
// VULNERABLE - Any authenticated user can access
[Authorize]
public class AdminController : ControllerBase
{
[HttpPost("users")]
public IActionResult CreateUser([FromBody] UserDto user)
{
// Missing role check - any authenticated user can create users!
_userService.CreateUser(user);
return Created();
}
}
Why this is vulnerable: [Authorize] alone only proves the caller is authenticated. Without a role or policy requirement, any logged-in user - not just administrators - can call a privileged operation.
Class-Level AllowAnonymous Overriding Method-Level Authorize
// VULNERABLE - Method-level Authorize is ignored
[AllowAnonymous] // Class-level
public class ProblemsController : ControllerBase
{
[Authorize] // This is ignored! AllowAnonymous at class level wins
public IActionResult SecureMethod()
{
// Still accessible without authentication!
return Ok(_sensitiveData);
}
}
Why this is vulnerable: A method-level [Authorize] cannot override a class-level [AllowAnonymous]. This is the inverse of the usual "most specific attribute wins" assumption developers bring from other attribute-based systems.
The Collection Action Beside the Protected One
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id, [FromServices] IAuthorizationService authService)
{
// Correctly protected: a resource-based check against the loaded order
var order = await _db.Orders.FindAsync(id);
var result = order is null
? AuthorizationResult.Failed()
: await authService.AuthorizeAsync(User, order, "CanViewOrder");
return result.Succeeded ? Ok(order) : NotFound();
}
// VULNERABLE - the list action beside it returns every order in the
// table to any authenticated caller
[HttpGet]
public async Task<IActionResult> ListOrders()
=> Ok(await _db.Orders.ToListAsync());
}
Why this is vulnerable: IAuthorizationService.AuthorizeAsync(user, resource, policy) needs a resource to evaluate, and a collection action supplies none - so the handler that guards GetOrder has nothing to fire on for ListOrders and is absent. Nothing in the file looks unguarded: [Authorize] is on the class and a resource-based check is visibly present one action above. It is also the cheaper attack, because it needs no ID guessing - one request returns the table.
[Authorize(Policy = "CanViewOrder")] is the tempting patch and cannot work here, because the attribute is evaluated by the authorization middleware before the action runs and has no resource to hand the handler. Measured on .NET 10: applying a resource policy that way still reaches AuthorizationHandler<TRequirement, TResource>.HandleAsync, but context.Resource is the HttpContext rather than the entity, so the typed HandleRequirementAsync(context, requirement, TResource resource) overload never runs, the requirement is never succeeded, and the endpoint returns 403 to everyone - the owner included. The same policy reached through IAuthorizationService.AuthorizeAsync(User, order, "CanViewOrder") fired the typed overload with context.Resource set to the Order, and the action above returned 200 with the order to its owner. A policy attribute can express "which callers may reach this action"; only the resource-based call, or the query itself, can express "which rows this caller may see".
Secure Patterns
Controller-Level Authorization with Selective Anonymous Access
// SECURE - Authorize at class level, selective anonymous
[Authorize] // All methods require authentication by default
[ApiController]
[Route("api/[controller]")]
public class UserController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
// Requires authentication and returns only data this principal may see
var user = _userService.GetUserForCurrentUser(id, User);
return Ok(user);
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")] // Additional role requirement
public IActionResult DeleteUser(int id)
{
_userService.DeleteUser(id);
return NoContent();
}
[HttpGet("public")]
[AllowAnonymous] // Explicitly allow public access
public IActionResult GetPublicInfo()
{
return Ok(new { Version = "1.0" });
}
}
Why this works:
- Applying
[Authorize]at the controller class level makes authentication the default for every action, including any method added later, without requiring each new endpoint to remember an attribute. - Method-level
[Authorize(Roles = "Admin")]layers an additional requirement on top of the class-level authentication check forDeleteUser. [AllowAnonymous]onGetPublicInfo()makes the anonymous exception explicit and visible to reviewers.- Resource-specific authorization (see below) is still required for endpoints that return or mutate a specific user's or tenant's data - class-level
[Authorize]proves the caller is logged in, not that they own the record.
Role-Based Authorization
// SECURE - Proper role checks for privileged operations
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
[HttpGet("dashboard")]
[Authorize(Roles = "Admin")]
public IActionResult GetDashboard()
{
return Ok(_dashboardService.GetData());
}
[HttpPost("users")]
[Authorize(Roles = "Admin,Manager")] // Multiple roles (OR logic)
public IActionResult CreateUser([FromBody] UserDto user)
{
_userService.CreateUser(user);
return Created();
}
}
Why this works:
[Authorize(Roles = "...")]declaratively restricts access to specific role claims; ASP.NET Core validates the authenticated user's role claims against the attribute before the action executes.- Comma-separated roles (
"Admin,Manager") implement OR logic - either role is sufficient. - Role checks live in the method signature, not scattered through imperative
ifstatements, which keeps authorization visible during review. - Role claims are populated during authentication (JWT, cookie, etc.) and validated by the authorization middleware automatically, with no custom code required in the action.
Policy-Based Authorization
// SECURE - Using policies for complex requirements beyond simple role checks
// Program.cs
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAdminRole", policy =>
policy.RequireRole("Admin"));
options.AddPolicy("AtLeast21", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(21)));
});
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
// Controller
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
[HttpGet("dashboard")]
[Authorize(Policy = "RequireAdminRole")]
public IActionResult GetDashboard()
{
return Ok(_dashboardService.GetAdminDashboard());
}
}
// Custom requirement and handler for logic a role check cannot express
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; }
public MinimumAgeRequirement(int minimumAge) => MinimumAge = minimumAge;
}
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var dob = context.User.FindFirst(c => c.Type == ClaimTypes.DateOfBirth);
if (dob != null && Convert.ToDateTime(dob.Value) <= DateTime.Today.AddYears(-requirement.MinimumAge))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Why this works:
- Policy logic is centralized in
Program.csviaAddAuthorization(), so rules can change without touching every controller that references them. [Authorize(Policy = "RequireAdminRole")]names the intent while the implementation lives in the policy definition and handler.- Custom
IAuthorizationRequirement/AuthorizationHandler<T>pairs (registered withAddSingleton<IAuthorizationHandler, ...>) support logic simple role checks cannot express, such as age-from-claims or time-of-day rules, with access to the fullAuthorizationHandlerContext. - Policies are reusable across controllers and actions, avoiding copy-pasted authorization logic.
Resource-Based Authorization
// SECURE - User can only edit their own resources
// Program.cs - the policy named below has to exist, and something has to
// decide it. AuthorizeAsync throws InvalidOperationException ("No policy
// found: CanEditProfile.") for a name that was never registered
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanEditProfile", policy =>
policy.Requirements.Add(new ResourceOwnerRequirement()));
});
// Scoped, not singleton: an ownership handler usually needs a DbContext or
// another per-request service, and a singleton cannot hold one
builder.Services.AddScoped<IAuthorizationHandler, ProfileOwnerHandler>();
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ProfileController : ControllerBase
{
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProfile(
int id,
[FromBody] ProfileDto profile,
[FromServices] IAuthorizationService authService)
{
var userProfile = await _profileService.GetProfileAsync(id);
// One decision, one response. "Does not exist" and "not yours" both
// leave here as 404 - see the note below
var authResult = userProfile is null
? AuthorizationResult.Failed()
: await authService.AuthorizeAsync(User, userProfile, "CanEditProfile");
if (!authResult.Succeeded)
{
return NotFound();
}
await _profileService.UpdateProfileAsync(id, profile);
return NoContent();
}
}
// Resource-based authorization handler
public class ResourceOwnerRequirement : IAuthorizationRequirement { }
public class ProfileOwnerHandler : AuthorizationHandler<ResourceOwnerRequirement, Profile>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ResourceOwnerRequirement requirement,
Profile resource)
{
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId != null && resource.UserId.ToString() == userId)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Why this works:
- This is the fix for IDOR-style findings: the authorization decision is evaluated against the specific resource instance (
userProfile), not just the caller's role, so one authenticated user cannot edit another user's profile by guessing anid. IAuthorizationService.AuthorizeAsync()is called imperatively inside the action, after the resource has been loaded, with the currentUser, the loaded resource, and the policy name.- The three parts have to line up or nothing enforces anything: the policy name in the action, the
AddPolicyregistration that maps it toResourceOwnerRequirement, and theAddScoped<IAuthorizationHandler, ProfileOwnerHandler>()registration that supplies the handler for that requirement. A missing policy registration throws on first use, which is loud. A missing handler registration is the quiet failure: the requirement is never succeeded, soauthResult.Succeededis false for everyone and the endpoint returns its denial response -404in the action above - to its legitimate owner. Both directions are worth a test. - The handler compares
resource.UserIdto the authenticated user'sNameIdentifierclaim - ownership logic lives in one reusable place instead of being duplicated across every action that touches aProfile.
Both failures leave by the same door, and that is deliberate. The obvious shape - if (userProfile is null) return NotFound(); above the AuthorizeAsync call, then Forbid() below it - splits one decision across two response paths and rebuilds the oracle the ownership check was added to close: /api/profile/4187 answers 403 and /api/profile/4188 answers 404, so the pair enumerates the table one request at a time. It is worse than it looks, because the missing-record branch runs first and answers even for a caller the policy would have refused outright. Folding the null case into the same AuthorizationResult leaves one status for every way the request can fail.
Forbid() is still the honest answer where the decision does not depend on a resource the caller named - a role that may not reach the action at all, or an endpoint whose existence is not a secret. Standardising on 403 for both branches is equally sound; what leaks is the pair, not either status on its own.
Ownership in the Query
// SECURE - the owner is a term in the query, so the constraint reaches the
// collection action as well as the single-resource one
public class OrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
private IQueryable<Order> ScopedTo(ClaimsPrincipal user) =>
user.IsInRole("Admin")
? _db.Orders
: _db.Orders.Where(o => o.OwnerId == user.FindFirstValue(ClaimTypes.NameIdentifier));
public Task<Order?> FindForCallerAsync(int id, ClaimsPrincipal user) =>
ScopedTo(user).SingleOrDefaultAsync(o => o.Id == id);
public Task<List<Order>> ListForCallerAsync(ClaimsPrincipal user) =>
ScopedTo(user).ToListAsync();
}
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly OrderRepository _orders;
public OrdersController(OrderRepository orders) => _orders = orders;
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id)
=> await _orders.FindForCallerAsync(id, User) is { } order ? Ok(order) : NotFound();
[HttpGet]
public async Task<IActionResult> ListOrders()
=> Ok(await _orders.ListForCallerAsync(User));
}
Why this works: Ownership is part of what is asked rather than a check applied to the answer, so the row is never materialised for a caller who may not have it. ScopedTo() is the whole control and both actions go through it - which is what makes the collection action safe, since it has no resource for a policy handler or an ownership comparison to attach to.
This is the pattern to reach for when a finding names a GET that returns a list, and it composes with resource-based authorization rather than replacing it: use IAuthorizationService.AuthorizeAsync where the decision is more than "is this row mine" - a shared record, a delegated permission, a state machine that only allows edits before dispatch - and let the scoped query carry the ownership half so an action that forgets the imperative call is still constrained.
Minimal API Authorization
// SECURE - Minimal APIs use a fluent equivalent to the attribute model
app.MapGet("/api/public", () => "Public data")
.AllowAnonymous();
app.MapGet("/api/private", () => "Private data")
.RequireAuthorization();
app.MapGet("/api/admin", () => "Admin data")
.RequireAuthorization("RequireAdminRole");
app.MapPost("/api/orders", (Order order, ClaimsPrincipal user, IOrderService orderService) =>
{
// Create the order for the authenticated user; never trust a user ID in the body
var created = orderService.CreateForUser(order, user);
return Results.Created($"/api/orders/{created.Id}", created);
})
.RequireAuthorization(policy => policy.RequireRole("Customer", "Admin"));
Why this works:
RequireAuthorization()/RequireAuthorization("PolicyName")/AllowAnonymous()are the minimal API equivalents of[Authorize]/[Authorize(Policy = "...")]/[AllowAnonymous]and are evaluated by the same authorization middleware.RequireAuthorization()applied to a route group does not automatically extend to endpoints mapped outside that group - each endpoint's effective authorization still needs to be verified individually, the same way class-level attributes need checking on MVC/Web API controllers.- Handlers should read identity from
ClaimsPrincipal user, not from request body fields, to prevent a caller from creating or modifying data under another user's identity.
Fail-Closed Fallback Policy
// SECURE - New endpoints require authentication even if a developer forgets [Authorize]
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
var app = builder.Build();
app.UseAuthentication(); // Must run before UseAuthorization
app.UseAuthorization(); // Enables [Authorize] attributes and RequireAuthorization()
app.MapControllers();
app.Run();
Why this works:
FallbackPolicyapplies to any endpoint that has no authorization metadata at all, changing the default from "anonymous" to "authenticated." A newly added controller or minimal API endpoint that forgets[Authorize]/RequireAuthorization()fails closed instead of being silently public.- Endpoints that are genuinely public still need an explicit
[AllowAnonymous]/.AllowAnonymous()even with a fallback policy in place - the fallback only fills in endpoints with no metadata. UseAuthentication()must run beforeUseAuthorization()in the middleware pipeline; reversing the order leavesHttpContext.Userunpopulated when authorization runs, causing authenticated requests to be rejected.
Testing
A re-scan sees [Authorize] on the action and stops there. It cannot tell a policy that permits the right callers from one that permits nobody, it cannot see an [AllowAnonymous] inherited from the class, and it cannot see which of two denials a caller received. Drive these through WebApplicationFactory<Program> with two authenticated principals, alice and bob:
- The owner still gets their own record.
alicerequesting her own order returns200with the order body, andPUTon it returns204. Put this first: a resource policy applied as[Authorize(Policy = ...)]rather than throughIAuthorizationService.AuthorizeAsyncdenies everyone, and aProfileOwnerHandlerthat was registered as a policy but never as anIAuthorizationHandlerdoes the same - both pass every rejection assertion below unchanged. - The two denials are indistinguishable.
alicerequestingbob's order andalicerequesting an ID that matches no row return the same status and the same body. Measured against the scoped repository above on .NET 10:200with the order for the owner,404for another user's ID,404for a nonexistent one. The resource-basedUpdateProfilebehaves the same way one status up -204for the owner,404for both denials. A403for one and a404for the other maps the table one request at a time. - The collection action is scoped.
GET /api/ordersasalicereturns exactly her orders - assert the IDs and the count, not200. A resource-based check needs a resource and a list action has none, so this endpoint passes an object-level review while returning every row. - Anonymous is refused where it should be. Call every endpoint with no credentials and assert
401. This is the assertion that catches a class-level[AllowAnonymous]silently disabling a method-level[Authorize], which no amount of reading the action finds - and with aFallbackPolicyin place it is also what proves a newly added endpoint inherited it. - The wrong role is refused on the action itself. An authenticated caller holding a role not named in
[Authorize(Roles = "...")]receives403, not200. Assert this for each role string separately:"Admin,Manager"is OR, so a test that only exercisesAdminsays nothing about whetherManagerwas meant to be there. - Route grouping did not leave anything behind. Enumerate the application's endpoints (
IEndpointRouteBuilder/EndpointDataSourcein the test host) and assert each one carries either authorization metadata or a deliberateAllowAnonymous. Minimal API endpoints mapped outside a group withRequireAuthorization()do not inherit it, and nothing in the mapping call shows that. - Client-supplied identity fields are ignored.
POSTa body carrying another user's ID, then read the persisted row and assert its owner is the caller'sNameIdentifierclaim. A response that omits the field is not evidence it was not written.
Common Pitfalls
- Assuming
[Authorize]alone covers object-level access: A controller-level[Authorize]proves the caller is logged in and satisfies any role/policy on the action, but it says nothing about whether the caller owns the specific record referenced byid- endpoints returning or mutating a specific resource still need an explicit ownership or resource-based check (IAuthorizationService.AuthorizeAsync) before acting on it. - Not noticing an inherited or class-level
[AllowAnonymous]: Because[AllowAnonymous]always wins over[Authorize]regardless of which level it's declared at, a class marked[AllowAnonymous]silently disables every method-level[Authorize]in that controller - grep for[AllowAnonymous]across the codebase when auditing, not just for missing[Authorize]. - Relying on minimal API route grouping without re-verifying each endpoint:
RequireAuthorization()applied to a route group does not automatically extend to endpoints mapped outside that group, and a stray.AllowAnonymous()on one endpoint in an otherwise-protected group is easy to miss in review. - Skipping the fallback policy and relying on per-endpoint attributes alone: Without a
FallbackPolicyrequiring an authenticated user, a newly added controller or minimal API endpoint that forgets[Authorize]/RequireAuthorization()is anonymous by default rather than failing closed. - Reimplementing authorization checks in custom middleware: Hand-written
app.Use(...)middleware that checkscontext.User.IsInRole(...)can enforce access control, but it is easy to place beforeUseAuthentication(), match the wrong path prefix, or miss a route entirely. Prefer[Authorize], policies, andRequireAuthorization(), which run through the same authorization middleware for every endpoint; if custom middleware is unavoidable, it must run afterUseAuthentication()and before the protected endpoint executes.