Securing It

Lock down the orders API with JWT bearer tokens, scope and owner checks, a secure fallback policy, per-customer rate limits and CORS.

Syntaxbuilder.Services.AddAuthorizationBuilder() .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()) .AddPolicy("orders:write", p => p.RequireAssertion(c => Scopes.Has(c.User, "orders.write")));

Security for the project is four layers, each answering a different question. Authentication: who sent this token, and is it genuine? Authorization: may this caller do this, to this order? Rate limiting: how much may they do? CORS: may a browser page from another origin read the response? Getting one right does not cover the others.

JWT bearer

AddJwtBearer downloads the identity provider's signing keys from {Authority}/.well-known/openid-configuration, caches them and validates signature, issuer, audience and lifetime (with five minutes of clock skew by default). Set Audience to this API's identifier. Without it every token fails audience validation (IDX10208), and the common wrong fix, ValidateAudience = false, makes the API accept tokens issued for any other API on the same provider. Set MapInboundClaims = false so sub stays sub instead of becoming a long XML-namespace claim type. The API never issues tokens; it only validates them.

Policies, fallback and ownership

SetFallbackPolicy with RequireAuthenticatedUser() makes every endpoint without its own metadata require a signed-in caller, so a new endpoint is closed by default; health checks opt out with AllowAnonymous(). Scopes become named policies: the policy name uses a colon (orders:write) and the scope value in the token a dot (orders.write), as in JWT Bearer Tokens, so a token from dotnet user-jwts create --scope orders.write passes. A JWT scope claim is one space-separated string ("orders.read orders.write"), so RequireClaim("scope", "orders.write") never matches it; split the value, as the demo does. Entra ID uses scp instead, and some providers send one claim per scope, so accept both shapes.

Policies answer "may this caller cancel orders"; only the handler can answer "is this their order". The handler compares the order's customer with sub and returns 404 for someone else's order unless the caller is staff. Returning 403 would confirm the id exists.

CallerResultProduced by
No or invalid token401Authentication challenge
Valid token, missing scope403Authorization forbid
Valid token, another customer's order404Owner check in the handler
Over the write limit429 with Retry-AfterRate limiter

Rate limiting

Partition by identity, not by IP: behind a load balancer or a corporate NAT, thousands of customers share one address. Anonymous traffic falls back to the client IP, which requires UseForwardedHeaders with the proxy configured as known, or every request appears to come from the ingress. Put UseRateLimiter() after UseAuthentication() so the partition sees the user, and after UseAuthorization() so rejected callers do not use up permits. The limiter is per process: three replicas allow three times the limit, which is fine for abuse protection and wrong for billing quotas, which belong in a shared store.

CORS

CORS relaxes the browser's same-origin rule; it is not access control, and non-browser clients ignore it. List exact origins per environment from configuration, expose the headers the shop reads (Location, Retry-After) and allow the custom Idempotency-Key request header, or the browser's preflight fails before the POST is sent. Never combine AllowAnyOrigin with credentials; the framework refuses it, and reflecting the request's Origin to get around that is the classic mistake.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Answer GET /api/orders/1002 with 404 when customer-42 asks for customer-77's order, while staff with the staff role get 200.
  • Allow each customer 3 order placements a minute across retries, returning 429 with Retry-After instead of letting a buggy client flood payments.
  • Let only https://shop.example.com call the API from the browser, including the Idempotency-Key header in the CORS preflight.

More examples

The production registration

Only the authority and the CORS origins differ between environments, and both come from configuration. <code>MapInboundClaims = false</code> plus explicit name and role claim types make <code>User.Identity.Name</code> and <code>IsInRole</code> read the claims the provider actually sends. <code>UseForwardedHeaders</code> runs first so the rate limiter and logs see the client's address rather than the ingress controller's, and only proxies inside the listed network are trusted to set it.

Example · csharp
using System.Security.Claims;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];   // e.g. https://login.example.com/realms/shop
        options.Audience = "orders-api";
        options.MapInboundClaims = false;                              // keep "sub", "scope", "roles" as sent
        options.TokenValidationParameters.NameClaimType = "sub";
        options.TokenValidationParameters.RoleClaimType = "roles";
    });

builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
    .AddPolicy("orders:read", p => p.RequireAssertion(c => HasScope(c.User, "orders.read")))
    .AddPolicy("orders:write", p => p.RequireAssertion(c => HasScope(c.User, "orders.write")));

// UseRateLimiter throws at startup unless AddRateLimiter registered its services.
builder.Services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    o.AddPolicy("order-writes", http => RateLimitPartition.GetFixedWindowLimiter(
        partitionKey: http.User.FindFirst("sub")?.Value ?? http.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 3, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});

var origins = builder.Configuration.GetSection("Cors:ShopOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(o => o.AddPolicy("shop-web", p => p
    .WithOrigins(origins)
    .WithMethods("GET", "POST")
    .WithHeaders("Authorization", "Content-Type", "Idempotency-Key", "If-Match")
    .WithExposedHeaders("Location", "Retry-After", "ETag")));

builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
    o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    o.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.0.0.0/8"));                 // the cluster's pod network
});

var app = builder.Build();
app.UseForwardedHeaders();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.Run();

static bool HasScope(ClaimsPrincipal user, string scope) =>
    user.FindAll(c => c.Type is "scope" or "scp")
        .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
        .Contains(scope);

Per-environment security settings

Nothing here is a secret: a bearer API needs no client secret, only the authority URL whose public keys it trusts. Listing origins explicitly, including both the bare and <code>www</code> hosts, avoids the late-night 'CORS error' that appears when marketing switches the shop's canonical host. <code>AllowedHosts</code> answers 400 to any request whose Host header is not listed, so it must name every host the API is reached by: the public name, and the Service names that in-cluster callers use. Kubelet probes send the pod IP as Host, so the probes in the shipping lesson set a <code>Host</code> header that passes the filter.

Example · json
// appsettings.Production.json
{
  "Auth": {
    "Authority": "https://login.example.com/realms/shop"
  },
  "Cors": {
    "ShopOrigins": [ "https://shop.example.com", "https://www.shop.example.com" ]
  },
  "AllowedHosts": "orders.example.com;orders-api;orders-api.shop.svc.cluster.local"
}

A scope requirement instead of an assertion

A requirement and handler pair is the reusable form of the assertion: it can take services through the constructor, log why access was denied and be unit tested with a hand-built <code>ClaimsPrincipal</code>. All requirements in a policy must succeed, so <code>orders:refund</code> needs both the staff role and the write scope. Not calling <code>Succeed</code> is enough to deny; call <code>Fail</code> only when no other handler should be able to grant it.

Example · csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IAuthorizationHandler, ScopeHandler>();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("orders:write", p => p.RequireAuthenticatedUser().AddRequirements(new ScopeRequirement("orders.write")))
    .AddPolicy("orders:refund", p => p.RequireAuthenticatedUser().RequireRole("staff")
        .AddRequirements(new ScopeRequirement("orders.write")));

var app = builder.Build();
app.Run();

public sealed record ScopeRequirement(string Scope) : IAuthorizationRequirement;

public sealed class ScopeHandler(ILogger<ScopeHandler> logger) : AuthorizationHandler<ScopeRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ScopeRequirement requirement)
    {
        var granted = context.User.FindAll(c => c.Type is "scope" or "scp")
            .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));

        if (granted.Contains(requirement.Scope, StringComparer.Ordinal))
            context.Succeed(requirement);
        else
            logger.LogInformation("Caller {Subject} lacks scope {Scope}", context.User.FindFirstValue("sub"), requirement.Scope);

        return Task.CompletedTask;
    }
}

Discussion

  • Be the first to comment on this lesson.