Public vs Private APIs in ASP.NET Core — Branching the Middleware Pipeline (Production‑Minded, with a Smile)
Source: Dev.to
Table of Contents
- The Problem in One Sentence
- Why Pipeline Branching Is the Right Pattern
- UseWhen by Prefix (Recommended)
- A1) Run middlewares only for Public
/_api - A2) Run middlewares only for Private
/api - A3) Keep
Startupclean with extension methods
- A1) Run middlewares only for Public
- Option B – Endpoint‑Metadata Profiles (Enterprise Flex)
- Option C – Route Groups (Minimal APIs)
- DI: “Don’t Register” vs “Don’t Execute”
- Production Notes (OData + Controllers + Ordering)
- Common Pitfalls
- Final Recommendation
The Problem in One Sentence
You need different middleware chains for requests that hit /api/... versus /_api/... without mixing routing logic into each middleware.
Why Pipeline Branching Is the Right Pattern
| Benefit | Explanation |
|---|---|
| Separation of concerns | Middlewares stay “pure” – no route‑specific code inside them. |
| Single source of truth | All route‑based security policies live in one place (Startup or extensions). |
| Performance | Requests that don’t match a branch never instantiate/execute those middlewares. |
| Evolvability | Easy to extend from simple prefixes to richer endpoint metadata later. |
UseWhen by Prefix (Recommended)
The cleanest solution when your contracts already have clear prefixes (
/_apiand/api).
A1) Run middlewares only for Public /_api
// ------------------------------------------------------------
// 1️⃣ Place after UseRouting() and before endpoint mapping
// ------------------------------------------------------------
app.UseRouting();
// ✅ Branch: only /_api
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments(
"/_api", StringComparison.OrdinalIgnoreCase),
branch =>
{
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
A2) Run middlewares only for Private /api
app.UseRouting();
// ✅ Branch: only /api
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments(
"/api", StringComparison.OrdinalIgnoreCase),
branch =>
{
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
A3) Keep Startup Clean with Extension Methods
// ──────────────────────────────────────────────────────────────
// Extension methods that 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();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
});
}
public static IApplicationBuilder UsePrivateApiSecurity(this IApplicationBuilder app)
{
return app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments(
"/api", StringComparison.OrdinalIgnoreCase),
branch =>
{
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
});
}
}
Usage in Configure
app.UseRouting();
app.UsePublicApiSecurity(); // or app.UsePrivateApiSecurity();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Option B — Endpoint‑Metadata Profiles (Enterprise Flex)
When you need exceptions (e.g., some /_api endpoints should skip the stack, or some /api endpoints should opt‑in), move the decision from path to metadata.
1️⃣ Define a custom attribute
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method,
AllowMultiple = false)]
public sealed class SecurityMiddlewareProfileAttribute : Attribute
{
public SecurityMiddlewareProfileAttribute(string profile) => Profile = profile;
public string Profile { get; }
}
2️⃣ Centralise profile constants
public static class SecurityProfiles
{
public const string Public = "public";
public const string Private = "private";
public const string None = "none"; // skip all security middlewares
}
3️⃣ Annotate controllers / actions
[SecurityMiddlewareProfile(SecurityProfiles.Public)]
[Route("_api/report/v1/[controller]")]
public class ReportController : ControllerBase
{
// …
}
4️⃣ Branch based on endpoint metadata (after routing)
app.UseRouting();
app.UseWhen(ctx =>
{
var endpoint = ctx.GetEndpoint();
var profile = endpoint?.Metadata
.GetMetadata<SecurityMiddlewareProfileAttribute>()
?.Profile;
// Execute the stack only for the "public" profile (adjust as needed)
return string.Equals(profile, SecurityProfiles.Public,
StringComparison.OrdinalIgnoreCase);
},
branch =>
{
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Now you can fine‑tune per‑endpoint behaviour without touching the middleware code.
Option C — Route Groups (If You Move to Minimal APIs)
var publicGroup = app.MapGroup("/_api")
.AddEndpointFilter(async (context, next) =>
{
// optional per‑group filter logic
return await next(context);
});
publicGroup.UseMiddleware();
publicGroup.UseMiddleware();
publicGroup.UseMiddleware();
publicGroup.UseMiddleware();
publicGroup.UseMiddleware();
publicGroup.MapGet("/report/v1/{id}", (int id) => /* … */);
Minimal‑API route groups give you the same branching power with a more fluent syntax.
DI: “Don’t Register” vs “Don’t Execute”
| Approach | When to use |
|---|---|
Don’t register the middleware in ConfigureServices | If the middleware is expensive to construct and will never be needed for a given deployment. |
| Don’t execute (branching) | When the middleware is cheap to construct but should only run for a subset of requests. |
In most cases, branching (
UseWhen) is sufficient because the middleware pipeline is built once at startup; the branch simply skips execution for non‑matching requests.
Production Notes (OData + Controllers + Ordering)
- Place
UseWhenafterUseRouting– endpoint metadata (including OData routes) isn’t available before routing. - Before
UseAuthentication/UseAuthorizationif those middlewares must run outside the branch (e.g., you still want auth on private endpoints).- If you want auth only on the public branch, move the auth calls inside the branch.
- OData route registration (
app.UseEndpoints(endpoints => endpoints.MapODataRoute(...))) should be after the branch so the branch can see the final endpoint. - Ordering of security middlewares matters – keep the same order inside each branch as you would in a linear pipeline.
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
Branch placed before UseRouting | ctx.GetEndpoint() is null; prefix checks may still work but later routing‑based metadata won’t. | Move the branch after app.UseRouting(). |
Using StartsWith instead of StartsWithSegments | /api2/... incorrectly matches /api. | Use StartsWithSegments for segment‑aware matching. |
Forgetting to call app.UseAuthentication() inside the branch when needed | Requests are unauthenticated inside the branch. | Add auth middleware inside the branch or keep it globally if it should apply to all. |
| Registering the same middleware twice (once globally, once in a branch) | Duplicate work, possible side‑effects. | Register only where needed. |
Relying on HttpContext.Request.Path after URL rewriting (e.g., UsePathBase) | Path may be altered, causing mismatches. | Use ctx.Request.PathBase + Path or adjust predicate accordingly. |
Final Recommendation
- Start with the prefix‑based
UseWhenapproach – it’s the simplest, performant, and keeps your middlewares pure. - Encapsulate the branching logic in extension methods to keep
Startup.Configuretidy. - When you need per‑endpoint exceptions, switch to endpoint‑metadata profiles (Option B).
- If you adopt Minimal APIs, consider route groups (Option C) for a more fluent experience.
By branching the pipeline you achieve a clean separation of concerns, avoid unnecessary middleware execution, and retain the flexibility to evolve your security model as your API surface grows.
Controlling Middleware per Endpoint in ASP.NET Core
Below is a cleaned‑up version of the original discussion, preserving the same structure and content.
Branching with UseWhen
app.UseWhen(context => context.Request.Path.StartsWithSegments("/_api"), branch =>
{
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
branch.UseMiddleware();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
✅ Result: You control middleware behavior per endpoint declaratively.
Minimal‑API Alternative – Route Groups
If you ever migrate to Minimal APIs, route groups give you a “chef’s kiss” syntax:
var publicApi = app.MapGroup("/_api");
publicApi.UseMiddleware();
publicApi.UseMiddleware();
var privateApi = app.MapGroup("/api");
// privateApi.UseMiddleware();
Note: Controllers don’t support route groups in the same first‑class way, so for now
UseWhenremains the safest approach.
“Don’t Execute” vs. “Don’t Register”
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 middleware to prevent execution:
| Situation | Behaviour |
|---|---|
Inside UseWhen(...) and the route doesn’t match | • Middleware is not executed. • It is typically not resolved (DI won’t create the instance). |
| Outside any conditional branch | Middleware runs for every request. |
So the real objective is “not executing”; “not registering” usually adds unnecessary complexity.
If you still want to avoid registration for certain deployments, treat it as an environment‑specific configuration (e.g., only register some middlewares in production).
Ordering Matters
UseRouting() must run before any branch that checks route metadata.
| Placement | Effect |
|---|---|
After UseRouting() (or even before) – prefix check | Clean and works for most scenarios. |
After UseRouting() – endpoint‑metadata check | Required because metadata isn’t available earlier. |
After endpoint mapping (MapControllers(), MapHub(), etc.) | The branch won’t affect requests. |
Before UseRouting() – metadata check | GetEndpoint() will be null. |
Typical pipeline layout
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting(); // ("/callshub");
Common Pitfalls
- Putting
UseWhentoo late (after endpoint mapping) → it never runs. - Inspecting endpoint metadata before
UseRouting()→GetEndpoint()isnull. - Mixing prefix checks and metadata without a clear rule → you may see middleware running twice.
- Coupling middleware logic to “public/private” → keep middlewares pure and let the pipeline decide when they run.
Recommendation
-
Start with Option A – prefix‑based
UseWhen.
It matches your current API surfaces (/_apivs/api) and ships quickly. -
When you need finer control per endpoint, evolve to Option B – metadata profiles (e.g., custom endpoint metadata attributes).
-
Keep middlewares composable and pure; keep the “when to run them” logic in the pipeline configuration.
Next Steps
If you paste how your Controllers are routed (including any OData routes), I can generate the exact UseWhen block and the safest ordering for your app so it won’t clash with OData routing or UseEndpoints().
Happy shipping! 🚀