ASP.NET Core API authorization failures often begin before a policy is evaluated: a newly added action has no authorization metadata at all. A project may register authentication correctly and protect several controllers with [Authorize], yet still leave every unannotated action open because no global rule exists. That design depends on every developer remembering an attribute during every change. A safer boundary protects MVC actions by default, treats [AllowAnonymous] as a narrow and audited exception, and prevents public helper methods on base controllers from becoming routes. The boundary should be demonstrated with HTTP status codes and endpoint discovery, not inferred from a login screen or the appearance of the source code.
Why ASP.NET Core APIs Need Authorization by Default
Direct answer: authentication registration does not automatically restrict controller actions. If an action has no authorization metadata and the application has no global rule, an anonymous request may reach it. A secure baseline protects new actions automatically and requires an explicit decision for every public exception.
Authentication answers who the caller is. Authorization answers whether that caller may use a resource. ASP.NET Core keeps those responsibilities separate, which is useful but easy to misread. Adding an authentication handler does not cause every controller to require an authenticated user. An [Authorize] declaration, a policy attached to the endpoint, an MVC authorization filter, or a fallback policy must establish the requirement.
A controller-by-controller approach works only while the inventory is small and every contributor applies the same convention. As the application grows, actions are copied, controllers are split, shared base classes appear, and new maintenance endpoints are added under time pressure. If the default is anonymous access, one omitted attribute changes the external surface. If the default is denial, the same omission normally produces a visible 401 during integration testing.
A secure default also reduces ambiguity during review. Reviewers no longer need to ask whether the absence of [Authorize] is intentional. The absence means the global rule still applies. Public access must be represented by a specific exception, so the diff clearly shows that the exposure changed.
The server remains the authority. A hidden button, a client-side route guard, or a menu permission can improve the user experience, but none of them prevents a direct HTTP request. Verification must call the API without an authenticated identity and assert the response. A page that appears inaccessible in the browser is not evidence that its backing action is protected.
This guide is specifically about establishing a default authorization boundary for ASP.NET Core controller APIs. It is different from articles about a particular sign-in format, claims transformation, or a specialized requirement handler. The question here is what happens when a new action has no local authorization annotation.
Choosing a Global AuthorizeFilter or a Fallback Policy
Direct answer: use a global AuthorizeFilter for a shared MVC authorization requirement, but do not assume that global MVC options affect only API controllers. Use a fallback policy when endpoints across the server-side routing system should require authenticated users by default. Before choosing, map controllers, Minimal APIs, Razor Pages, static files, and middleware order.
Microsoft defines the fallback policy as the policy selected when endpoint metadata does not produce another authorization policy. That behavior makes it a strong default for a server-side application in which most endpoints should be protected. A new endpoint without [Authorize] still encounters the fallback policy rather than silently becoming public.
An MVC AuthorizeFilter establishes an authorization requirement in the MVC filter pipeline. Global MVC options can also affect Razor Pages registered in the same application. The AddControllers registration syntax does not guarantee an API-only boundary. The filter does not automatically protect Minimal APIs, and shared MVC pages must be included in migration tests.
The two mechanisms are not interchangeable configuration styles. A fallback policy is enforced by authorization middleware. Whether that middleware handles static resources or unmatched requests depends on the pipeline. Microsoft notes that static files served before authorization middleware are not protected by the fallback policy. If the order differs, public pages and their assets need explicit testing. A configuration copied from another application cannot prove the boundary of the current application.
The following is a configuration excerpt from a controllers-only sandbox, not a production authentication implementation. The complete DemoAuthenticationHandler and project are included in the verification section:
builder.Services.AddAuthentication("Demo")
.AddScheme("Demo", _ => { });
builder.Services.AddAuthorization();
builder.Services.AddControllers(options =>
{
var policy = new AuthorizationPolicyBuilder("Demo")
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
Copying the snippet is not the complete task. The application must also place authentication and authorization middleware in the correct order, map controllers correctly, inventory non-controller endpoints, and test public resources. If Minimal APIs should share the same default, a fallback policy may express the intent more consistently. To target one API controller family, an authorization attribute on its common base controller can provide a scoped approach, subject to inheritance tests. Do not assume a global filter leaves other MVC or Razor Pages surfaces unchanged.
The durable decision rule is to define the protected resource set first and then select the mechanism that covers it. Starting with a familiar API and assuming it covers the whole application reverses that reasoning and creates blind spots.
Treat AllowAnonymous as a Narrow Public Exception
Direct answer: [AllowAnonymous] bypasses MVC authorization requirements, so it is an external-interface decision rather than a cosmetic annotation. Apply it only to the smallest necessary action, document why public access is required, constrain inputs and outputs, and test that neighboring actions remain protected.
Some endpoints must be public: a health indicator, an entry page, selected public content, or a callback designed for callers without an application session. Public does not mean unconstrained. Each exception should identify the allowed HTTP method, accepted input shape, response fields, rate protections, and error behavior. The response should disclose only what the use case requires.
Microsoft's MVC authorization documentation warns that [AllowAnonymous] causes authorization declarations on the same controller or action to be ignored. That is why a controller-level exception deserves special scrutiny. A class may contain one public action today, then gain an administrative action later. The new action inherits the class metadata, and a reviewer looking only at the method may miss the inherited exception.
A better structure puts public actions in narrowly focused controllers and applies [AllowAnonymous] at the action level or on a controller whose only purpose is public access. An endpoint inventory and automated tests should make every new anonymous route an explicit change. The tests should not stop after proving that the public action returns 200. They should also prove that an adjacent unannotated action returns 401.
Public exceptions need lifecycle management. A merge checklist can record an owner, rationale, and review point. If the calling application later supports authenticated requests, the exception should be removed rather than kept as historical configuration. The goal is not to eliminate all public routes; it is to ensure every public route is intentional, bounded, observable, and reversible.
The client behavior around 401 also matters during a migration. A caller that treats an authorization failure as an empty successful result can silently skip a safety check or continue with incomplete data. Before tightening a server boundary, inventory callers and make authorization failures visible. Fail closed at the caller as well as the server.
Prevent Base-Controller Helpers from Becoming Actions
Direct answer: ASP.NET Core MVC treats public controller methods as actions unless they are marked [NonAction]. A public helper inherited from a base controller can therefore become routable. Prefer protected for controller-only helpers, move business logic into services, and use [NonAction] when public visibility must remain.
Base controllers often collect repeated operations such as building responses, reading current-user information, validating common input, or composing query parameters. A developer may make a helper public for convenience without considering action discovery. When a derived controller uses an [action] route segment, the inherited method name can become part of a reachable URL.
Microsoft's controller documentation states the convention directly: public methods on a controller are actions unless [NonAction] excludes them. This should be treated as an architectural rule, not a detail reviewers are expected to remember. Use the smallest visibility that supports the design. A helper used only by derived controllers should be protected. Logic that does not require controller state should move into an injected service. If compatibility requires a public method, mark it [NonAction] and add a routing test.
[NonAction] separates ordinary object methods from MVC metadata, but it should not be used to keep substantial business operations inside a controller hierarchy. Thin controllers are easier to inventory because their public methods are expected to be HTTP actions. Services provide a clearer place for reusable logic and do not participate in MVC action discovery.
Inventory tools must look beyond [HttpGet] and [HttpPost]. Conventional routing and controller-level route templates can make a public method reachable without a method-level route attribute. The reliable inventory comes from runtime endpoint data, generated API descriptions, or integration tests. Source inspection is useful, but endpoint existence is ultimately a runtime claim.
Inheritance makes this especially important. One public helper on a base class can affect many derived controllers. Adding one new controller may multiply the number of reachable routes without changing the base class in the same pull request. An endpoint snapshot makes that expansion visible.
Verification and Reproduction with Observable HTTP Results
Direct answer: start a minimal ASP.NET Core application locally and test an unannotated action, an [AllowAnonymous] action, an inherited public helper, and a [NonAction] helper. The expected evidence is five responses in order: 401, 200, 200, 200, and 404, not merely a successful build.
The scheduled verification actually ran with .NET SDK 10.0.400 and runtime 10.0.11. The named sandbox uses a fictional in-memory identity and a random local port. It does not connect to a database, an external service, or a real account. The program starts Kestrel and sends requests through HttpClient in the same process, which keeps the reproduction isolated and deterministic.
- In a new isolated directory, create
verification/aspnetcore_global_authorization_20261009and save the two complete files below in that subdirectory. Run these commands from the isolated directory. The observed SDK was 10.0.400; the project fixes the net10.0 target framework, not SDK selection. Record your actual SDK and runtime and rerun the checks if either differs:
dotnet --version
dotnet run --project verification/aspnetcore_global_authorization_20261009/aspnetcore_global_authorization_20261009.csproj --configuration Release
Request the unannotated
Pingaction without the sandbox identity header. The expected status is 401. Repeat with the demonstration identity header; the expected status is 200. If the anonymous call returns 200, the global filter did not create the intended baseline and the process exits with a failure.Request
PublicInfo, which is explicitly marked[AllowAnonymous], without an identity. The expected status is 200. This confirms that a narrow public exception remains functional while the general baseline denies anonymous calls.Request
ExposedHelper, inherited as a public method from the base controller, with the demonstration identity. The expected status is 200. This step intentionally proves the action-discovery hazard; it is not a recommended design.Request
HiddenHelper, which carries[NonAction], with the same demonstration identity. The expected status is 404. Any result other than 404 fails this sandbox assertion. In a different application, a fallback policy or custom middleware can return 401 even without a matched endpoint, so 401 alone does not prove that the helper became routable.
The real execution reported SUMMARY 5/5 checks passed on .NET 10.0.11. The checks observed an anonymous 401 for the unannotated action, an authenticated 200 for that action, an anonymous 200 for the intentional public action, an authenticated 200 for the inherited public helper, and a 404 for the [NonAction] helper.
Those results prove the behavior of the named sandbox, not every application pipeline. A real project must rerun the same assertions with its route templates, authentication schemes, filter ordering, endpoint mappings, and hosting configuration. The purpose of the sandbox is to establish a reproducible framework baseline and a test pattern that can be transferred without using real application data.
A useful extension is to export the runtime endpoint list before and after a change. The build should fail when a new anonymous route appears outside an approved list. That check turns route exposure into a reviewable artifact instead of a manual code search.
Complete isolated sandbox files
Local demonstration only. The Demo handler trusts any nonempty X-Demo-User header without verifying a real identity. Never deploy it as production authentication. The server binds only to a random loopback port. These checks cover the global MVC filter, not fallback policy, Razor Pages, role permissions, or resource-level authorization.
Save this as aspnetcore_global_authorization_20261009.csproj:
net10.0
enable
enable
Program.cs:
using System.Net;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();
builder.Services
.AddAuthentication("Demo")
.AddScheme("Demo", _ => { });
builder.Services.AddAuthorization();
builder.Services.AddControllers(options =>
{
var policy = new AuthorizationPolicyBuilder("Demo")
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
await app.StartAsync();
var server = app.Services.GetRequiredService();
var address = server.Features.Get()?.Addresses.Single()
?? throw new InvalidOperationException("Kestrel did not expose a bound address.");
using var client = new HttpClient { BaseAddress = new Uri(address) };
var checks = new List();
checks.Add((
"unannotated action is denied to an anonymous request",
(await client.GetAsync("/api/Sample/Ping")).StatusCode,
HttpStatusCode.Unauthorized));
var authenticatedPing = new HttpRequestMessage(HttpMethod.Get, "/api/Sample/Ping");
authenticatedPing.Headers.Add("X-Demo-User", "reader");
checks.Add((
"unannotated action accepts an authenticated request",
(await client.SendAsync(authenticatedPing)).StatusCode,
HttpStatusCode.OK));
checks.Add((
"AllowAnonymous action remains intentionally public",
(await client.GetAsync("/api/Sample/PublicInfo")).StatusCode,
HttpStatusCode.OK));
var exposedHelper = new HttpRequestMessage(HttpMethod.Get, "/api/Sample/ExposedHelper");
exposedHelper.Headers.Add("X-Demo-User", "reader");
checks.Add((
"inherited public helper is discovered as an action",
(await client.SendAsync(exposedHelper)).StatusCode,
HttpStatusCode.OK));
var hiddenHelper = new HttpRequestMessage(HttpMethod.Get, "/api/Sample/HiddenHelper");
hiddenHelper.Headers.Add("X-Demo-User", "reader");
checks.Add((
"NonAction helper is not routable",
(await client.SendAsync(hiddenHelper)).StatusCode,
HttpStatusCode.NotFound));
var passed = 0;
foreach (var check in checks)
{
if (check.Actual == check.Expected)
{
passed++;
Console.WriteLine($"PASS {check.Name}: {(int)check.Actual}");
}
else
{
Console.WriteLine($"FAIL {check.Name}: expected {(int)check.Expected}, got {(int)check.Actual}");
}
}
Console.WriteLine($"SUMMARY {passed}/{checks.Count} checks passed on .NET {Environment.Version}");
await app.StopAsync();
return passed == checks.Count ? 0 : 1;
public sealed class DemoAuthenticationHandler : AuthenticationHandler
{
public DemoAuthenticationHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-Demo-User", out var user) || string.IsNullOrWhiteSpace(user))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, user.ToString()) },
Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
public abstract class ApiControllerBase : ControllerBase
{
public IActionResult ExposedHelper() => Ok(new { result = "base helper" });
[NonAction]
public IActionResult HiddenHelper() => Ok(new { result = "hidden helper" });
}
[ApiController]
[Route("api/[controller]/[action]")]
public sealed class SampleController : ApiControllerBase
{
[HttpGet]
public IActionResult Ping() => Ok(new { result = "protected" });
[AllowAnonymous]
[HttpGet]
public IActionResult PublicInfo() => Ok(new { result = "public" });
}
Expected output, reproduced in this environment:
PASS unannotated action is denied to an anonymous request: 401
PASS unannotated action accepts an authenticated request: 200
PASS AllowAnonymous action remains intentionally public: 200
PASS inherited public helper is discovered as an action: 200
PASS NonAction helper is not routable: 404
SUMMARY 5/5 checks passed on .NET 10.0.11
The process exits 0 when all assertions match, including expected denials, and nonzero on a mismatch. A 401 is this Demo scheme's challenge result, not a universal guarantee for cookie or custom schemes. Requiring an authenticated user is only a baseline; roles, tenants, and resource permissions require their own authorization rules.
Audit Callers Before Tightening the Server Boundary
Direct answer: changing a server from permissive to secure-by-default can reveal clients that omitted authentication or mishandled 401 responses. Inventory every caller, confirm how it supplies identity, and require authorization failures to stop the workflow visibly rather than becoming an empty result.
A server-side fix can expose client defects that were previously hidden. Browser code may have one request path that bypasses a shared HTTP wrapper. A scheduled process may interpret any non-success response as an empty collection. A user interface may count repeated 401 responses and sign the user out. These behaviors do not justify leaving the API open, but they must be discovered before the change is released.
Create a caller matrix for each endpoint family. Record the caller type, request method, expected identity mechanism, handling of 401 and 403, retry behavior, and owner. Exercise the caller against a test deployment with the new default. A client passes only when it either supplies the required identity or stops with a clear failure for an endpoint that should remain protected.
Do not preserve broad anonymous access merely because a server-to-server caller currently omits authentication. If a temporary exception is unavoidable, isolate the endpoint, minimize its response, add compensating controls, assign an owner, and create a removal condition. The exception should remain visible in the same inventory as browser-facing public actions.
This caller review is also where semantic failures appear. Returning an empty list after 401 can cause later logic to skip checks, assume there is nothing to process, or report success despite missing data. The correct behavior is usually to propagate the authorization failure, stop the operation, and make the condition observable.
The migration therefore has two gates: the server must deny anonymous access by default, and callers must handle denial safely. Passing only the first gate can replace an exposure defect with a silent workflow defect.
Release Checklist for a Verifiable Authorization Boundary
Direct answer: review the default policy, every public exception, controller action discovery, caller behavior, and endpoint-inventory differences together. A successful authenticated request is not enough; release evidence must also prove anonymous denial and the absence of unintended helper routes.
Use the following checklist as a code-review and release gate:
- Does a new controller action return 401 to an anonymous request when it has no local annotation?
- Is the choice between fallback policy and a global MVC filter aligned with the intended resource set?
- Do controllers, Minimal APIs, Razor Pages, and static files each have an explicit rule?
- Is every
[AllowAnonymous]declaration limited to the smallest practical scope? - Does every public action have a documented purpose, constrained method, bounded input, and minimal response?
- Do controllers and their base classes contain unnecessary public helper methods?
- Can each helper become
protected, move to a service, or be marked[NonAction]? - Do callers stop visibly on 401, or can they mistake failure for an empty successful result?
- Is the runtime endpoint inventory compared before and after the change?
- Do integration tests make anonymous requests directly rather than relying on a signed-in browser path?
Operational measurements should follow the same boundary. Track 401 and 403 rates by route, anonymous successful responses, public-route traffic changes, newly discovered endpoints, and differences between the runtime endpoint inventory and the approved anonymous list. These signals cannot replace tests, but they can reveal a caller regression or an unexpectedly exposed route after deployment.
The durable principle is simple: public access is the exception, protection is the default, and routes are proven by runtime evidence. A global rule removes dependence on memory, [AllowAnonymous] creates a visible public decision, and protected or [NonAction] prevents helper methods from joining the HTTP surface. Combined with caller audits and endpoint tests, these controls turn ASP.NET Core API authorization from a convention into a verifiable contract.
Official references: