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.

Create a free account to save this page, take notes and track your progress. Sign in

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 AddJwtBearer for 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 custom IAuthorizationRequirement handlers.
  • Make endpoints secure by default with a fallback policy and mark public ones with AllowAnonymous().

The code

An auth scheme, policies and a custom ownership requirement
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 if statements 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.

Was this page helpful?