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.
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:
429 without a token ever being validated, so a throttled caller never reaches a
handler, a validator, or the idempotency store.UseAppConfig registers
that. The AddErrorResponses(...) registration in
Minimal.Api/Configs/FluentValidationConfig.cs inserts UseExceptionHandler() at the head of the
pipeline through a start-up filter of its own, so it wraps every stage in the table — see
Error responses.UseAppConfig. The first
rewrites the caller’s address before CORS or the rate limiter reads it; the second writes its
headers from HttpResponse.OnStarting, so an unhandled 500 carries them even though the error
handler sits upstream of it.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.
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:
ForwardedHeaders.None. Nothing is honoured: Connection.RemoteIpAddress
stays the immediate peer and Request.Scheme stays what the peer actually used. This is
deliberate. ForwardedHeadersMiddleware’s own restriction check is a no-op when KnownProxies and
KnownIPNetworks are both empty — it then trusts the header from any peer — so “no trusted
proxy configured” has to switch the feature off outright to actually mean “trust nobody”.X-Forwarded-For and X-Forwarded-Proto are applied, but only when
the immediate peer is a listed address (TrustedProxies) or inside a listed range
(TrustedNetworks). KnownProxies and KnownIPNetworks are cleared first, so ASP.NET Core’s
seeded loopback entry is gone unless you list 127.0.0.1 yourself.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.
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.
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.
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 inCors:AllowedOriginsfor the environment it talks to.
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).
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.
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:
Minimal.Api/Configs/AppConfig.cs only calls AddAuthConfig() (JWT bearer auth,
Minimal.Api/Configs/Auth/AuthConfig.cs) when it’s true.UseEndpointConfigs as o.RequireAuthorization, which is applied to
every route group after mapping and before IEndpointConfig.Map runs.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] populationRegistered 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.
[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.
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.
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.
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).
| 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:
FeatureManagement:RequireAuthorization is false in appsettings.Development.json and
appsettings.Testing.json, so no authentication middleware runs there and HttpContext.User is
unauthenticated. A local dotnet run and both test suites therefore see every sensitive property
withheld, the role-less one included. That is the fail-closed rule working, not a
misconfiguration — a test that needs the full payload has to authenticate the caller.JsonSerializerOptions — a background job, an outbox message, your own ILogger output — is
unfiltered. Filtering is also response-side only: it never touches request deserialization or
model binding.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 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:
AddIdempotencyWithRedisStore(redisConnectionString, o =>
o.ConflictHandling = IdempotentConflictHandling.ConflictResponse): keys are tracked in Redis, so
idempotency works correctly across multiple app instances.AddIdempotentKey(...) (same ConflictResponse
conflict handling), an in-process store. Fine for local development, not for a multi-instance
deployment.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.
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.
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:
title = "Error", for all threestatus — the same status the response line carriestype = the status’s name (InternalServerError, Conflict, Forbidden), never the
exception’s type name. It is recomputed whenever the setting overrides the status, so it always
names the status actually answeredtraceId — the current Activity id, falling back to the request’s TraceIdentifiererrors — a JSON array of { message, code?, field? }. code is the machine-readable
discriminator the failure itself carries; field is the input member a validation failure names,
and is absent for a command failureAn 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:
PreconditionCodes.Prefix-prefixed code answers 409 instead of
the default 400, and the body gains that value as a code member
(Minimal.AppServices/Share/PreconditionCodes.cs)OwnershipRequiredException answers 403, so a refused write is a controlled refusal rather than
an internal error — see
auditing-and-data-ownership.mdFeatureManagement:EnableHealthCheck, default true) — a CoreDbContext
connectivity check plus a custom HealthCheckHandler, mapped by
Minimal.Api/Configs/Healthz/HealthzConfig.cs at three paths:
/healthz and / — anonymous (.AllowAnonymous(), so the default-deny fallback policy does
not apply). Both evaluate every registered check, so a service whose database is unreachable
does not report healthy, but the body is the overall status and nothing else —
{"status":"Healthy"}. No check name, duration, description or exception text is ever written
to this surface, in any state./healthz/detail — the full per-check report, carrying .RequireAuthorization().FeatureManagement:EnableSwagger, default false) —
Minimal.Api/Configs/Swagger/SwaggerConfig.cs maps the OpenAPI 3.0 document and a Scalar UI at
/docs, pre-configured with a Bearer-auth scheme. Outside Development both carry
.RequireAuthorization(), so a deployed service’s API surface is not readable by whoever finds
the URL; in Development they stay anonymous.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.