DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

Public vs Private APIs in ASP.NET Core — Branching the Middleware Pipeline (Production‑Minded, with a Smile)

Public vs Private APIs in ASP.NET Core — Branching the Middleware Pipeline (Production‑Minded, with a Smile)<br>

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:

  • DenySecretsInUrlMiddleware
  • BlockSensitiveQueryStringMiddleware
  • TokenAgeGuardMiddleware
  • SecuritySignalsMiddleware
  • AuthProblemDetailsMiddleware

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

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();
Enter fullscreen mode Exit fullscreen mode

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>();
    });
Enter fullscreen mode Exit fullscreen mode

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>();
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

Use it in your Configure():

app.UseRouting();

app.UsePublicApiSecurity();  // or app.UsePrivateApiSecurity();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
Enter fullscreen mode Exit fullscreen mode

Option B — Endpoint Metadata Profiles (Enterprise Flex)

Prefix-based routing is great… until you want exceptions like:

  • some /_api endpoints should skip middlewares,
  • or some /api endpoints 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";
}
Enter fullscreen mode Exit fullscreen mode

2) Mark your controllers/actions

[SecurityMiddlewareProfile(SecurityProfiles.Public)]
[Route("_api/report/v1/[controller]")]
public class ReportController : ControllerBase
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

✅ 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<...>();
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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 (UseWhen by prefix): it matches your current API surfaces (/_api vs /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)