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

Published: (January 19, 2026 at 03:20 PM EST)
6 min read
Source: Dev.to

Source: Dev.to

Table of Contents

  1. The Problem in One Sentence
  2. Why Pipeline Branching Is the Right Pattern
  3. UseWhen by Prefix (Recommended)
    • A1) Run middlewares only for Public /_api
    • A2) Run middlewares only for Private /api
    • A3) Keep Startup clean with extension methods
  4. Option B – Endpoint‑Metadata Profiles (Enterprise Flex)
  5. Option C – Route Groups (Minimal APIs)
  6. DI: “Don’t Register” vs “Don’t Execute”
  7. Production Notes (OData + Controllers + Ordering)
  8. Common Pitfalls
  9. 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

BenefitExplanation
Separation of concernsMiddlewares stay “pure” – no route‑specific code inside them.
Single source of truthAll route‑based security policies live in one place (Startup or extensions).
PerformanceRequests that don’t match a branch never instantiate/execute those middlewares.
EvolvabilityEasy to extend from simple prefixes to richer endpoint metadata later.

The cleanest solution when your contracts already have clear prefixes (/_api and /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”

ApproachWhen to use
Don’t register the middleware in ConfigureServicesIf 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)

  1. Place UseWhen after UseRouting – endpoint metadata (including OData routes) isn’t available before routing.
  2. Before UseAuthentication / UseAuthorization if 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.
  3. OData route registration (app.UseEndpoints(endpoints => endpoints.MapODataRoute(...))) should be after the branch so the branch can see the final endpoint.
  4. Ordering of security middlewares matters – keep the same order inside each branch as you would in a linear pipeline.

Common Pitfalls

PitfallSymptomFix
Branch placed before UseRoutingctx.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 neededRequests 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

  1. Start with the prefix‑based UseWhen approach – it’s the simplest, performant, and keeps your middlewares pure.
  2. Encapsulate the branching logic in extension methods to keep Startup.Configure tidy.
  3. When you need per‑endpoint exceptions, switch to endpoint‑metadata profiles (Option B).
  4. 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 UseWhen remains 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:

SituationBehaviour
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 branchMiddleware 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.

PlacementEffect
After UseRouting() (or even before) – prefix checkClean and works for most scenarios.
After UseRouting()endpoint‑metadata checkRequired because metadata isn’t available earlier.
After endpoint mapping (MapControllers(), MapHub(), etc.)The branch won’t affect requests.
Before UseRouting()metadata checkGetEndpoint() will be null.

Typical pipeline layout

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();               // ("/callshub");

Common Pitfalls

  • Putting UseWhen too late (after endpoint mapping) → it never runs.
  • Inspecting endpoint metadata before UseRouting()GetEndpoint() is null.
  • 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

  1. Start with Option A – prefix‑based UseWhen.
    It matches your current API surfaces (/_api vs /api) and ships quickly.

  2. When you need finer control per endpoint, evolve to Option B – metadata profiles (e.g., custom endpoint metadata attributes).

  3. 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! 🚀

Back to Blog

Related posts

Read more »

WebForms Core in NuGet

Overview WebForms Core, the server‑driven UI technology developed by Elanat, is now officially available on NuGet under the package name WFC. The package lets...

Stop Using IOptions Wrong in .NET!

IOptions vs IOptionsSnapshot vs IOptionsMonitor – Which One Should You Use? Ever got confused about which one to use in your .NET app? You're not alone! Let me...