Public vs Private APIs in ASP.NET Core — Branching the Middleware Pipeline (Production‑Minded, with a Smile)
Imagine you run two API “surfaces” in the same ASP.NET Core app:
-
Public:
/_api/...(external clients, OData, public contracts) -
Private:
/api/...(internal services, admin portals, trusted callers)
And you have a set of security middlewares like:
DenySecretsInUrlMiddlewareBlockSensitiveQueryStringMiddlewareTokenAgeGuardMiddlewareSecuritySignalsMiddlewareAuthProblemDetailsMiddleware
But you only want them to execute for one surface (e.g., public only, or private only) — without polluting each middleware with “public/private” logic.
The most professional way in ASP.NET Core is to branch the request pipeline.
It’s basically Chain of Responsibility, but split into branches based on a predicate.
Table of Contents
- Release Overview
- The Problem in One Sentence
- Why Pipeline Branching Is the Right Pattern
-
Option A —
UseWhenby Prefix (Recommended) - Option B — Endpoint Metadata Profiles (Enterprise Flex)
- Option C — Route Groups (If You Move to Minimal APIs)
- DI: “Don’t Register” vs “Don’t Execute”
- Production Notes (OData + Controllers + Ordering)
- Common Pitfalls
- Final Recommendation
Release Overview
You’re building an API host that:
- Exposes both public and private endpoints:
-
/_api/report/v1/...(public) -
/api/report/v1/...(private)
-
- Uses Controllers + OData
- Has a security middleware stack that should apply only to specific surfaces.
This guide gives you multiple approaches, from fastest-to-ship to maximum flexibility.
The Problem in One Sentence
You want the same application to have different middleware chains depending on whether the request is for
/_api/...or/api/....
Why Pipeline Branching Is the Right Pattern
Because it gives you:
- Separation of concerns: middlewares stay “pure” (no route logic inside them).
- Single source of truth: route-based security policy lives in one place (Startup).
- Performance: requests that don’t match the branch don’t even instantiate/execute those middlewares.
- Evolvability: easy to extend from prefixes to endpoint metadata later.
Option A — UseWhen by Prefix (Recommended)
This is the cleanest solution when your contract already has clear prefixes.
A1) Run middlewares only for Public /_api
Use this when public requests should run your security middlewares, but private ones should not.
Place it after UseRouting() and before endpoint mapping (MapControllers() / UseEndpoints()):
app.UseRouting();
// ✅ Branch: only /_api
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/_api", StringComparison.OrdinalIgnoreCase),
branch =>
{
branch.UseMiddleware<DenySecretsInUrlMiddleware>();
branch.UseMiddleware<BlockSensitiveQueryStringMiddleware>();
branch.UseMiddleware<TokenAgeGuardMiddleware>();
branch.UseMiddleware<SecuritySignalsMiddleware>();
branch.UseMiddleware<AuthProblemDetailsMiddleware>();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
A2) Run middlewares only for Private /api
Same idea, different predicate:
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase),
branch =>
{
branch.UseMiddleware<DenySecretsInUrlMiddleware>();
branch.UseMiddleware<BlockSensitiveQueryStringMiddleware>();
branch.UseMiddleware<TokenAgeGuardMiddleware>();
branch.UseMiddleware<SecuritySignalsMiddleware>();
branch.UseMiddleware<AuthProblemDetailsMiddleware>();
});
A3) Keep Startup Clean with Extensions
Large Startup files become unreadable. Encapsulate the branching logic.
public static class ApiSecurityPipelineExtensions
{
public static IApplicationBuilder UsePublicApiSecurity(this IApplicationBuilder app)
{
return app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/_api", StringComparison.OrdinalIgnoreCase),
branch =>
{
branch.UseMiddleware<DenySecretsInUrlMiddleware>();
branch.UseMiddleware<BlockSensitiveQueryStringMiddleware>();
branch.UseMiddleware<TokenAgeGuardMiddleware>();
branch.UseMiddleware<SecuritySignalsMiddleware>();
branch.UseMiddleware<AuthProblemDetailsMiddleware>();
});
}
public static IApplicationBuilder UsePrivateApiSecurity(this IApplicationBuilder app)
{
return app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase),
branch =>
{
branch.UseMiddleware<DenySecretsInUrlMiddleware>();
branch.UseMiddleware<BlockSensitiveQueryStringMiddleware>();
branch.UseMiddleware<TokenAgeGuardMiddleware>();
branch.UseMiddleware<SecuritySignalsMiddleware>();
branch.UseMiddleware<AuthProblemDetailsMiddleware>();
});
}
}
Use it in your Configure():
app.UseRouting();
app.UsePublicApiSecurity(); // or app.UsePrivateApiSecurity();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Option B — Endpoint Metadata Profiles (Enterprise Flex)
Prefix-based routing is great… until you want exceptions like:
- some
/_apiendpoints should skip middlewares, - or some
/apiendpoints should opt-in.
Solution: endpoint metadata.
1) Define the profile attribute
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public sealed class SecurityMiddlewareProfileAttribute : Attribute
{
public SecurityMiddlewareProfileAttribute(string profile) => Profile = profile;
public string Profile { get; }
}
public static class SecurityProfiles
{
public const string Public = "public";
public const string Private = "private";
public const string None = "none";
}
2) Mark your controllers/actions
[SecurityMiddlewareProfile(SecurityProfiles.Public)]
[Route("_api/report/v1/[controller]")]
public class ReportController : ControllerBase
{
// ...
}
3) Branch using endpoint metadata
Important: endpoint metadata is available after routing.
app.UseRouting();
app.UseWhen(ctx =>
{
var ep = ctx.GetEndpoint();
var profile = ep?.Metadata.GetMetadata<SecurityMiddlewareProfileAttribute>()?.Profile;
return string.Equals(profile, SecurityProfiles.Public, StringComparison.OrdinalIgnoreCase);
},
branch =>
{
branch.UseMiddleware<DenySecretsInUrlMiddleware>();
branch.UseMiddleware<BlockSensitiveQueryStringMiddleware>();
branch.UseMiddleware<TokenAgeGuardMiddleware>();
branch.UseMiddleware<SecuritySignalsMiddleware>();
branch.UseMiddleware<AuthProblemDetailsMiddleware>();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
✅ Result: you control middleware behavior per endpoint, declaratively.
Option C — Route Groups (If You Move to Minimal APIs)
If you ever migrate to Minimal APIs, route groups make this chef’s kiss:
var publicApi = app.MapGroup("/_api");
publicApi.UseMiddleware<DenySecretsInUrlMiddleware>();
publicApi.UseMiddleware<BlockSensitiveQueryStringMiddleware>();
var privateApi = app.MapGroup("/api");
// privateApi.UseMiddleware<...>();
Controllers don’t support route groups in the same first-class way, so for now, UseWhen remains the safest.
DI: “Don’t Register” vs “Don’t Execute”
You said: “when private, I don’t want to execute them or even register them in dependencies.”
In ASP.NET Core, you don’t need to unregister them to prevent execution.
When you place them inside UseWhen(...):
- they are not executed for non-matching routes
- they are typically not resolved for non-matching routes
- your app stays simple and consistent (especially in multi-env)
“Not executing” is the real objective. “Not registering” is usually unnecessary complexity.
If you still want to avoid registration for some deployments, treat that as an environment configuration concern (e.g., only register certain middlewares in prod/public deployments).
Production Notes (OData + Controllers + Ordering)
Ordering rule of thumb
-
UseRouting()must run before any branch that checks route metadata. - Branching by path prefix can happen right after
UseRouting()(or even before), but it’s clean after routing. - Branching by endpoint metadata must be after
UseRouting().
Typical robust order
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// Branch here
app.UsePublicApiSecurity();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapControllers();
app.MapHub<CallsHub>("/callshub");
Common Pitfalls
- Putting UseWhen too late (after endpoint mapping): it won’t affect requests.
-
Inspecting endpoint metadata before UseRouting:
GetEndpoint()will be null. - Using both prefix checks and metadata without a clear rule: you’ll debug “why is middleware running twice?”
- Over-coupling middlewares to “public/private” logic: keep them composable and pure.
Final Recommendation
- Start with Option A (
UseWhenby prefix): it matches your current API surfaces (/_apivs/api) and ships fast. - When you need fine control per endpoint, evolve to Option B (metadata profiles).
- Keep middlewares pure; keep “when to run them” in the pipeline configuration.
If you paste how your Controllers are routed (including OData routes), I can produce the exact UseWhen block and the safest ordering for your app (so it won’t clash with OData routing or UseEndpoints()).
Happy shipping. 🚀

Top comments (0)