ASP.NET Core Web APIs · Configuration, Security, Data and Operations
Authentication Schemes and Authorization Policies
Authenticate callers with an authentication handler, authorize with policies and requirements, and return 401 versus 403 correctly.
What you'll build
An API-key authentication handler that turns a key into a ClaimsPrincipal (user ID, role, scopes), and endpoints protected by policies: a role policy, a scope policy and a custom requirement for resource ownership. Requests without credentials get 401; authenticated callers without permission get 403.
The idea
- Authentication answers "who is calling?" and builds a
ClaimsPrincipal; authorization answers "may they do this?". - Real APIs usually use
AddJwtBearerfor tokens from an identity provider; the handler here is a stand-in so the example runs offline. Policies work the same way. - Policies (
AddAuthorizationBuilder().AddPolicy(...)) combine requirements: roles, claims, or customIAuthorizationRequirementhandlers. - Make endpoints secure by default with a fallback policy and mark public ones with
AllowAnonymous().
The code
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder();
builder.Services.AddAuthentication("ApiKey").AddScheme<AuthenticationSchemeOptions, ApiKeyHandler>("ApiKey", null);
builder.Services.AddSingleton<IAuthorizationHandler, OwnerOrAdminHandler>();
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
.AddPolicy("Admin", p => p.RequireRole("admin"))
.AddPolicy("ReadReports", p => p.RequireClaim("scope", "reports:read"))
.AddPolicy("OwnerOrAdmin", p => p.AddRequirements(new OwnerOrAdminRequirement()));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/health", () => "ok").AllowAnonymous();
app.MapGet("/me", (ClaimsPrincipal user) => user.FindFirstValue(ClaimTypes.NameIdentifier));
app.MapGet("/reports", () => "report data").RequireAuthorization("ReadReports");
app.MapDelete("/users/{id}", (string id) => $"deleted {id}").RequireAuthorization("Admin");
app.MapGet("/users/{id}/invoices", async (string id, ClaimsPrincipal user, IAuthorizationService auth) =>
(await auth.AuthorizeAsync(user, id, "OwnerOrAdmin")).Succeeded ? Results.Ok($"invoices of {id}") : Results.Forbid());
app.Urls.Add("http://127.0.0.1:0");
await app.StartAsync();
using var http = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
foreach (var (method, path, key) in new[] {
("GET", "/health", ""), ("GET", "/me", ""), ("GET", "/me", "key-ana"), ("GET", "/reports", "key-ana"),
("GET", "/reports", "key-ops"), ("DELETE", "/users/u2", "key-ana"), ("DELETE", "/users/u2", "key-ops"),
("GET", "/users/u1/invoices", "key-ana"), ("GET", "/users/u2/invoices", "key-ana"), ("GET", "/users/u2/invoices", "key-ops") })
{
var request = new HttpRequestMessage(new HttpMethod(method), path);
if (key != "") request.Headers.Add("X-Api-Key", key);
var response = await http.SendAsync(request);
Console.WriteLine($"{method} {path} as {(key == "" ? "anonymous" : key)} -> {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
}
await app.StopAsync();
class ApiKeyHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
static readonly Dictionary<string, (string Id, string Role, string[] Scopes)> Keys = new()
{
["key-ana"] = ("u1", "customer", []),
["key-ops"] = ("u9", "admin", ["reports:read"]),
};
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var key = Request.Headers["X-Api-Key"].FirstOrDefault();
if (key is null) return Task.FromResult(AuthenticateResult.NoResult());
if (!Keys.TryGetValue(key, out var caller)) return Task.FromResult(AuthenticateResult.Fail("unknown key"));
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, caller.Id), new(ClaimTypes.Role, caller.Role) };
claims.AddRange(caller.Scopes.Select(s => new Claim("scope", s)));
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
}
}
class OwnerOrAdminRequirement : IAuthorizationRequirement;
class OwnerOrAdminHandler : AuthorizationHandler<OwnerOrAdminRequirement, string>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, OwnerOrAdminRequirement requirement, string userId)
{
if (context.User.IsInRole("admin") || context.User.FindFirstValue(ClaimTypes.NameIdentifier) == userId)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
Output
GET /health as anonymous -> 200 ok GET /me as anonymous -> 401 GET /me as key-ana -> 200 u1 GET /reports as key-ana -> 403 GET /reports as key-ops -> 200 report data DELETE /users/u2 as key-ana -> 403 DELETE /users/u2 as key-ops -> 200 deleted u2 GET /users/u1/invoices as key-ana -> 200 "invoices of u1" GET /users/u2/invoices as key-ana -> 403 GET /users/u2/invoices as key-ops -> 200 "invoices of u2"
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Checking roles with
ifstatements inside handlers instead of named policies. - Forgetting the fallback policy, so a new endpoint is public by accident.
- Role checks only: customers reading other customers' data. Ownership needs a resource-based check (
IAuthorizationService.AuthorizeAsync(user, resource, policy)). - Returning 401 when the caller is known but not allowed; that's 403.
Key points
- Authentication builds the ClaimsPrincipal; authorization evaluates policies.
- Use a fallback policy to secure every endpoint by default.
- Custom requirements and resource-based authorization handle ownership rules.
Further reading: Policy-based authorization · Resource-based authorization · JWT bearer authentication
Practice questions
What's the difference between 401 and 403?
Answer
401 means the caller isn't authenticated (no or invalid credentials); 403 means the caller is known but not allowed to perform the action.
What does a fallback authorization policy do?
Answer
It applies to endpoints that have no authorization metadata, so requiring an authenticated user there makes every endpoint secure by default unless marked AllowAnonymous.
When do you need resource-based authorization?
Answer
When the decision depends on the resource itself, such as whether the caller owns the invoice; the handler receives the resource via IAuthorizationService.AuthorizeAsync.
Open in the app to mark lessons complete, take notes and track your progress.