DKNet.Templates

API Request Pipeline

This page traces everything that happens to a request before it reaches a handler, in the order it actually runs. The feature guides cover what each handler does; this page covers only the pipeline stages upstream of it, which aren’t visible from reading a single handler.

At a glance

Every Default below is the value the shipped base appsettings.json produces — that is what an unmodified deployed service runs with, since the template ships no appsettings.Production.json. Where an environment overlay relaxes it, the row says so. Full flag matrix: docs/template-features.md.

# Stage Default
0 Kestrel request limits — max body size, header-read timeout, no Server header FeatureManagement:EnableRequestBounds = true (false in Development); server-level, not middleware
1 Forwarded headers (X-Forwarded-For, X-Forwarded-Proto) FeatureManagement:EnableForwardedHeaders = true (false in Development), Security:TrustedProxies and Security:TrustedNetworks both empty — forwarded values ignored
2 Security response headers FeatureManagement:EnableSecurityHeaders = true (false in Development)
3 Antiforgery cookie middleware FeatureManagement:EnableAntiforgery = false — not wired
4 CORS Cors:AllowedOrigins empty — CORS not wired
5 HSTS and HTTPS redirect FeatureManagement:EnableHttps = true (false in Development/Testing)
6 Health-check endpoints FeatureManagement:EnableHealthCheck = true
7 Routing and endpoint registration —
8 Request timeouts FeatureManagement:EnableRequestBounds = true; 30 s, then 504
9 Rate limiting FeatureManagement:EnableRateLimit = true (false in Development/Testing)
10 Authentication / authorization FeatureManagement:RequireAuthorization = true (false in Development/Testing)
11 OpenAPI/Scalar mapping EnableSwagger = false
12 [FromClaim] population (endpoint filter) —
13 FluentValidation auto-validation (endpoint filter) —
14 Idempotency on POST (endpoint filter) opt-in per route
15 Handler —

Rows 1–15 are registration order in Minimal.Api/Configs/AppConfig.cs’s UseAppConfig, which is execution order; row 0 is not middleware at all — those are Kestrel limits, enforced by the server before the first middleware sees the request. The sections below explain each stage, grouped for reading rather than re-sorted into that sequence — the table is the authority on what runs when. Two consequences are easy to get wrong:

Role-aware sensitive-property filtering is absent from the table for the same reason: it is not a middleware but a JsonSerializerOptions modifier, so it runs after row 15, while the handler’s result is serialized — see Role-aware sensitive-property filtering below.

API versioning is absent from the table because it is not a middleware. It shapes the route template when the group is registered — see API versioning below.

Workflow diagram of the request pipeline: a request passes the edge middleware that applies forwarded headers, security response headers and CORS, then routing with the request bounds and the rate limiter, then authentication with its default-deny fallback, then the endpoint filters that populate FromClaim members and run FluentValidation, and finally the handler; opt-in routes take a detour through the idempotency filter, and each stage has its own short-circuit response — 413 for an oversized body, 429 or 504, 401 or 403, 400, and the 500 problem+json the library's error handler writes.

Forwarded headers

Minimal.Api/Configs/ForwardedHeadersConfig.cs is the first middleware in the pipeline, gated on FeatureManagement:EnableForwardedHeaders (default true; false in the Development overlay). It reads the trusted-proxy list from Security:TrustedProxies and the trusted-range list from Security:TrustedNetworks — both empty in the shipped base file — and:

A caller that is not a listed proxy or inside a listed range therefore cannot claim another client’s address — its X-Forwarded-For is ignored and it is rate-limited as itself. Behind an ingress you do list, each client gets its own rate-limit partition instead of all of them sharing the ingress’s.

TrustedProxies entries are single IP addresses parsed with IPAddress.Parse; a CIDR range there is not accepted and fails at startup. For a proxy whose address is not fixed — a Kubernetes ingress, Container Apps — list its CIDR range in TrustedNetworks instead, parsed with IPNetwork.Parse. Keys: configuration.md.

Security response headers

Minimal.Api/Configs/SecurityHeadersConfig.cs (FeatureManagement:EnableSecurityHeaders, default true, false in the Development overlay) adds the OwaspHeaders.Core header set: X-Frame-Options, X-Content-Type-Options, a default Content-Security-Policy, X-Permitted-Cross-Domain-Policies, Referrer-Policy, Cache-Control, X-XSS-Protection and Cross-Origin-Resource-Policy.

It is registered second — before routing — and writes the headers from HttpResponse.OnStarting, so a 200, a 404 for an unpublished path and the 500 problem+json the error handler writes all carry them. The OnStarting detour is load-bearing, and it is what keeps the 500 covered even though the error handler sits upstream of this middleware: the exception-handler path clears the response before writing its own, which would drop headers added the ordinary way.

Strict-Transport-Security is not emitted here — Minimal.Api/Configs/HttpsConfig.cs owns that header, with its max-age from Https:HstsMaxAgeDays (default 365 days, and preload requested only at 365 days or more). One owner, so the header is never sent twice.

Request bounds

Minimal.Api/Configs/RequestBoundsConfig.cs (FeatureManagement:EnableRequestBounds, default true, false in the Development overlay) states three bounds the template would otherwise inherit from Kestrel, all from the RequestBounds section:

Bound Default Over the bound
RequestTimeoutSeconds 30 504 Gateway Timeout, from the default request-timeout policy
MaxRequestBodySizeBytes 1048576 (1 MB) 413 Payload Too Large, from Kestrel
RequestHeadersTimeoutSeconds 10 Kestrel drops the connection while the headers are still arriving

Only the timeout is middleware — UseRequestTimeouts() sits right after UseRouting(), as ASP.NET Core requires, which means it bounds endpoint execution. The other two are server limits enforced before any middleware runs, so a request that is both oversized and slow is rejected on size first. The same module sets AddServerHeader = false: no response names the web-server product.

CORS

Minimal.Api/Configs/CrosConfig.cs reads the Cors:AllowedOrigins string array and is deny-by-default. When the key is absent, the array is empty, or every entry is blank, neither AddCors(...) nor the UseCors() middleware is registered at all — a cross-origin request is still served, but the response carries no Access-Control-Allow-* header, so the browser refuses to hand it to the calling page. This is “not wired”, not “wired but permissive”. When the array is non-empty, the default policy allows exactly those origins and exactly the methods and headers enumerated in Cors:AllowedMethods (default GET, POST, PUT, PATCH — no DELETE) and Cors:AllowedHeaders (default Authorization, Content-Type, Accept, X-Idempotency-Key, the header the template’s own create route requires); an origin, method or header that isn’t listed is never reflected back, so its preflight fails. Credentials are never allowed — AllowCredentials() is not called on any path.

Both lists fall back to those defaults only when the key is absent. A key present but empty ([]) means “nothing allowed” and is not widened back. Per-key detail: configuration.md.

Entries are absolute origins: scheme included, no trailing slash and no path — https://app.example.com, not app.example.com or https://app.example.com/.

The checked-in Minimal.Api/appsettings.json ships an empty list, so a service deployed with the template defaults is closed to browsers. Minimal.Api/appsettings.Development.json lists the local SPA dev-server origins http://localhost:3000 and http://localhost:5173:

"Cors": {
  "AllowedOrigins": [ "http://localhost:3000", "http://localhost:5173" ]
}

UseCrosConfig() runs from Minimal.Api/Configs/AppConfig.cs before UseRouting(), so the policy covers every endpoint including the CORS preflight OPTIONS request.

Breaking behavioural change when you regenerate from the template. The previous revision registered AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod() unconditionally, so any web page could call a scaffolded service. A browser front-end that used to work must now have its origin listed in Cors:AllowedOrigins for the environment it talks to.

Routing and endpoint registration

Every route group is an IEndpointConfig (DKNet.AspCore.Extensions) — for example Minimal.Api/ApiEndpoints/ManualSample/PurchaseOrderV1Endpoint.cs and Minimal.Api/ApiEndpoints/AutomatedSample/ProductV1Endpoint.cs. Minimal.Api/Program.cs calls UseEndpointConfigs(...), which discovers every non-abstract IEndpointConfig in the app assembly. For each one it builds a versioned route group and calls its Map(RouteGroupBuilder).

API versioning

FeatureManagement:EnableVersioning (default true) is read in Minimal.Api/Program.cs and passed as o.EnableVersioning to UseEndpointConfigs. When enabled, each group’s route becomes /v{version:apiVersion}{GroupEndpoint} — for example /v1/purchase-orders. The host must already have called AddAppVersioning() (Minimal.Api/Configs/VersioningConfig.cs); otherwise registration throws at startup, before any endpoint is even discovered.

Authentication / authorization

FeatureManagement:RequireAuthorization — true in the base appsettings.json, so a deployed service authenticates by default; appsettings.Development.json and appsettings.Testing.json both set it to false, so dotnet run locally and both test suites stay anonymous. It drives two things at once:

Authorization is default-deny when the flag is on: AddAuthConfig() sets options.FallbackPolicy to RequireAuthenticatedUser(), so an endpoint that neither declares a policy nor declares itself anonymous still requires an authenticated caller — a route published outside a configured group is not anonymous by accident. The public health probes at /healthz and / are the declared exception (.AllowAnonymous()).

When it is false no authentication middleware is added at all — this is not a permissive policy but the absence of any identity, which is why the base file must never ship it off.

Minimal.App.Tests/Integration/EndpointConfig/PurchaseOrderStampingAndVersioningTests.cs pins the authorization-off behavior explicitly (see below).

Program.cs binds FeatureOptions from builder.Configuration in its first lines, before a WebApplicationFactory’s ConfigureAppConfiguration overrides are merged in. A test fixture therefore cannot flip this flag with an in-memory config entry — only an appsettings.{Environment}.json file or a FeatureManagement__RequireAuthorization environment variable lands early enough. See the remarks on Minimal.App.Tests/Integration/Support/AuthOnApiFixture.cs.

[FromClaim] population

Registered once via .AddContextualRequestPopulation() in Minimal.Api/Program.cs, and applied automatically by UseEndpointConfigs for every mapped endpoint. Any request property marked [FromClaim(...)] — for example ByUser on Minimal.AppServices/ManualSample/V1/Actions/Create.cs — is overwritten from the caller’s claim before validation and before the handler runs. This is a security property, not a model-binding convenience: whatever the caller put in the body or query string for that member is always discarded.

There is no configured fallback in the population pipeline itself — a [FromClaim] member is only ever set from an authenticated principal’s claims. What makes that true even with RequireAuthorization off — local Development and the test suites — is the built-in demonstration authentication provider (FeatureManagement:EnableDemoAuthentication, Minimal.Api/Configs/Auth/DemoAuthConfig.cs): when it is enabled it authenticates every caller under its own "Demo" scheme, issuing ClaimTypes.Name as SharedConsts.DemoAccount ("demo-user@not-a-real-identity.invalid"), so [FromClaim(ClaimTypes.Name)] resolves that value instead of going unset. RequireAuthorization and EnableDemoAuthentication can never both be true — Minimal.Api/Configs/AppConfig.cs throws an InvalidOperationException at start-up rather than silently preferring one provider over the other. An authenticated caller with a genuinely missing claim still never gets a fallback — the member holds its type’s default instead, and the handler must reject it explicitly (see CreatePurchaseOrderCommandHandler.OnHandle’s IsNullOrEmpty(request.ByUser) check). Pinned by DemoAuthentication_CreateIsAttributedToTheDemoIdentity and AuthenticatedCallerWithNoNameClaim_CreateIsRefused_NeverAttributedToSystemAccount in Minimal.App.Tests/Integration/EndpointConfig/PurchaseOrderStampingAndVersioningTests.cs.

Role-aware sensitive-property filtering

[FromClaim] decides what a caller may send. This decides what a caller may see: a property declared [SensitiveData(...)] on an entity, and carried onto the generated DTO (see docs/crud-attributes.md), is omitted from the JSON payload unless the caller holds one of the roles the declaration names.

It happens at the very end of the request, while the response is serialized, and it is opt-in per JsonSerializerOptions instance. Nothing is filtered until the host turns it on — which is two pieces of code in Minimal.Api.

1. The principal accessor

DKNet.EfCore.* references no Microsoft.AspNetCore.* package, so the library cannot read HttpContext itself. ISensitiveDataPrincipalAccessor is the seam it offers instead, and the host implements it — Minimal.Api/Configs/Handlers/HttpContextSensitiveDataPrincipalAccessor.cs:

using DKNet.EfCore.Extensions.Serialization;

internal sealed class HttpContextSensitiveDataPrincipalAccessor(IHttpContextAccessor httpContextAccessor)
    : ISensitiveDataPrincipalAccessor
{
    public ClaimsPrincipal? Current => httpContextAccessor.HttpContext?.User;
}

A singleton is correct here: it holds no state of its own, and IHttpContextAccessor — already registered by ServiceConfigs.AddAllAppServices — resolves the current request’s HttpContext from an AsyncLocal on every read.

2. The start-up opt-in

Registered in Minimal.Api/Configs/ServiceConfigs.cs, alongside the ConfigureHttpJsonOptions call that already sets this template’s naming policy and converters. Minimal APIs serialize with Microsoft.AspNetCore.Http.Json.JsonOptions, and the opt-in needs the container to resolve the accessor, so it goes in as an IConfigureOptions<JsonOptions>:

services.AddSingleton<ISensitiveDataPrincipalAccessor, HttpContextSensitiveDataPrincipalAccessor>();

services.AddSingleton<IConfigureOptions<JsonOptions>>(sp =>
    new ConfigureOptions<JsonOptions>(o =>
        o.SerializerOptions.UseRoleAwareSensitiveData(
            sp.GetRequiredService<ISensitiveDataPrincipalAccessor>())));

UseRoleAwareSensitiveData composes with whatever resolver, naming policy and converters the options already carry — it layers a per-property ShouldSerialize on top rather than replacing them, and a model with no sensitive property serializes byte-for-byte as it did before.

This must run at start-up. System.Text.Json freezes a JsonSerializerOptions on first use, and a frozen instance rejects the resolver change with InvalidOperationException — so the call belongs in service registration, never in an endpoint filter or a request handler.

What the caller sees

GET /v1/products/{id} for an authenticated caller in the pricing role (audit fields id/createdBy/createdOn/updatedBy/updatedOn elided for length):

{
  "name": "Espresso Machine",
  "price": 899.00,
  "supplierCostPrice": 412.50,
  "grossMargin": 486.50,
  "supplierReferenceCode": "SUP-88231",
  "isDiscontinued": false
}

grossMargin is Price minus SupplierCostPrice — a hand-mapped DTO property, not a column, that declares [SensitiveData("pricing")] because it discloses the same confidential number as the supplier cost price it is derived from (Minimal.AppServices/AutomatedSample/V1/ProductDto.cs:38-39). It is gated on role membership, not on whether a value exists: a caller in pricing sees the key even when no supplier cost price was ever recorded, holding an explicit null.

The same row, for an authenticated caller holding no roles:

{
  "name": "Espresso Machine",
  "price": 899.00,
  "supplierReferenceCode": "SUP-88231",
  "isDiscontinued": false
}

supplierCostPrice and grossMargin are absent — not null, not "***". Nothing in the payload hints that a property was withheld, and a client deserializing into a type with a nullable property simply sees null because nothing was assigned. supplierReferenceCode survives because its declaration names no role and this caller is authenticated.

Withheld is not the same as unset. This template sets DefaultIgnoreCondition = WhenWritingNull (SharedConsts.JsonSerializerOptions), so an ordinary nullable property that is unset is simply absent. A [SensitiveData] property is not subject to that: the per-property ShouldSerialize the resolver installs decides it, and for a caller who passes the role check the key is written even when its value is null (Minimal.App.Tests/Integration/AutomatedSample/V1/ProductGrossMarginTests.cs:107-123). So a pricing-role caller reading a product with no supplier cost price recorded gets "supplierCostPrice": null and "grossMargin": null; a caller outside the role gets neither key at all (same file, :129-142).

The decision rules

Caller [SensitiveData] [SensitiveData("pricing")]
No principal (Current is null) withheld withheld
Not authenticated withheld withheld
Authenticated, no roles sent withheld
Authenticated, in pricing sent sent
Authenticated, in some other role only sent withheld

It fails closed: with no caller identity, or an unauthenticated one, the property is withheld whichever roles were named — the role-less form included. The check runs per property, per serialization, reading the accessor as the payload is written, so two callers on the same endpoint through the same options instance are judged independently and nothing is cached.

Two consequences worth planning for in this template:

FluentValidation auto-validation

Minimal.Api/Configs/FluentValidationConfig.cs registers AddFluentValidationAutoValidation() and scans the AppServices assembly for AbstractValidator<T> implementations — for example CreatePurchaseOrderCommandValidator next to CreatePurchaseOrderRequest. A request failing validation never reaches a handler. It short-circuits to a 400 with FluentValidation’s problem-details shape, with no handler code involved.

The filter is attached per endpoint group — UseEndpointConfigs calls group.AddFluentValidationAutoValidation() on every group (Minimal.Api/Program.cs:50) — so it runs on generated routes too, not only hand-mapped ones. A validator written against a generated request (CreateProductRequestValidator, DeleteProductRequestValidator) therefore runs before the generated handler and may read stored data through IRepositorySpec to refuse; both of those answer 409. This is unrelated to the forwarded-DataAnnotations gap described in docs/samples/manual-vs-automated.md.

Idempotency on POST

Idempotency is opt-in per route, not automatic for every POST. Minimal.Api/ApiEndpoints/ManualSample/PurchaseOrderV1Endpoint.cs’s create route chains .RequiredIdempotentKey(), which enforces the idempotency key header (default X-Idempotency-Key) on that route — a request missing it is rejected before the handler runs. The automated sample’s generated create route (Minimal.Api/ApiEndpoints/AutomatedSample/ProductV1Endpoint.cs’s MapProductCrud()) makes no such call; a replayed request there is processed as a brand-new create, not deduplicated. Add .RequiredIdempotentKey() yourself on any route where duplicate submissions matter.

Store selection happens once in Minimal.Api/Configs/AppConfig.cs, based on whether ConnectionStrings:Redis is configured:

With IdempotentConflictHandling.ConflictResponse (this template’s setting), a replayed request with the same key returns the original cached response rather than re-running the handler or returning a conflict error.

Rate limiting

FeatureManagement:EnableRateLimit — true in the base appsettings.json, false in the Development and Testing overlays — wires Minimal.Api/Configs/RateLimits/RateLimitConfig.cs: a chained PartitionedRateLimiter combining a fixed-window limiter and a concurrency limiter, both keyed per-request by IRateLimitKeyProvider and configured per-request by IRateLimitOptionsProvider. A request over either limit is rejected with 429 Too Many Requests — and because UseRateLimiter() runs immediately after UseRouting() and before UseAuthConfig(), that rejection happens before the caller is authenticated and long before any endpoint filter runs.

The partition key comes from Minimal.Api/Configs/RateLimits/RateLimitKeyProvider.cs: User.Identity.Name, falling back to the remote IP address, falling back to the request host. Since the limiter runs before authentication, User.Identity.Name is only populated for a caller already authenticated by an earlier middleware — in practice the shipped pipeline partitions by IP.

That IP is Connection.RemoteIpAddress as the forwarded-headers middleware left it: the real client when the immediate peer is a proxy listed in Security:TrustedProxies or inside a range listed in Security:TrustedNetworks, and the immediate peer itself otherwise. The provider never parses X-Forwarded-For on its own, so an untrusted peer’s forwarded claim spends that peer’s own budget rather than someone else’s. Behind an ingress that is not listed — the shipped default, since both lists are empty — every client shares one partition; list the ingress’s address, or its CIDR range if it has no fixed address, and they are separated again. Both providers are public interfaces you can replace; see extension-points.md.

Limits come from the RateLimit section. The base appsettings.json sets it explicitly (DefaultRequestLimit: 100, DefaultConcurrentLimit: 20, TimeWindowInSeconds: 1) — without that section the limiter would fall back to RateLimitOptions’s class defaults of 2 requests per second, which is an outage rather than a rate limit. Treat the shipped numbers as a placeholder to tune.

Error responses

Minimal.Api/Configs/FluentValidationConfig.cs calls AddErrorResponses(...) once, and that call is the app’s only error-handling step. The library it comes from (DKNet.AspCore.Extensions) registers problem details, its own IExceptionHandler and a start-up filter that inserts UseExceptionHandler() at the head of the pipeline — so the template calls neither UseExceptionHandler() nor AddProblemDetails() anywhere, and there is no second place error responses can be configured instead.

That one registered setting answers all three failure kinds — a failed command handler, refused validation input and an unhandled exception — with the same body:

An unhandled exception answers 500 with a single errors entry. In Development that entry’s message is the exception’s own message; outside Development it is the fixed string "An unexpected error occurred. Quote the trace-id when reporting this." and nothing else the exception carried — no message, no type name, no stack trace. That is what makes traceId the correlation handle: once the message is generic, quoting it is the only way to tie the response back to the logged exception. A client never sees a raw stack trace.

Two branches sit on top of that standard body, both configured in the same registration:

Health checks and OpenAPI/Scalar

Both of those RequireAuthorization() calls need authorization middleware to evaluate them, and that is only wired when FeatureManagement:RequireAuthorization is on. With authorization off — the Development and Testing overlays — /healthz/detail is anonymous, and so is /docs in a non-Development environment. The base appsettings.json ships the flag on, so a deployed service protects both.