YARP (Yet Another Reverse Proxy) is a .NET library for building a reverse proxy as an ordinary ASP.NET Core app rather than deploying and configuring a separate piece of infrastructure. That matters once a system has more than a couple of backend services: something has to sit in front of them to route requests, balance load, check health, attach authentication and enforce rate limits, and doing that in C# means the same language, tests, deployment pipeline and observability stack as the rest of your platform. This guide covers routing, clusters, transforms, load balancing, health checks, session affinity, gateway authentication, the backend-for-frontend pattern, direct forwarding and edge rate limiting.

What Is YARP?#

YARP ships as the Yarp.ReverseProxy NuGet package and targets .NET 8 or later. Unlike a typical reverse proxy that is a standalone process you configure through its own file format, YARP is a set of ASP.NET Core middleware: you add it to Program.cs like any other service, and it runs inside a normal ASP.NET Core process with access to the same dependency injection container, configuration system, logging and middleware pipeline as the rest of your app.

That has two practical consequences. First, every extension point β€” routing, transforms, load balancing, health checks β€” is C# you can unit test and step through in a debugger, not a proprietary configuration language. Second, a YARP gateway deploys and scales the same way any other ASP.NET Core service does, which fits naturally into a .NET Aspire or Kubernetes-based system alongside the services it fronts.

How YARP Works: Routes, Clusters and the Proxy Pipeline#

YARP's model has two concepts. A route matches an incoming request β€” by path, host, method, headers or query parameters β€” and says which cluster should handle it. A cluster is a named group of backend destinations, plus how to pick among them: load balancing policy, health checks and session affinity.

Text
Request β†’ Route match β†’ Transforms (request) β†’ Load balancer picks a destination
        β†’ Forward to destination β†’ Transforms (response) β†’ Response to client

A request that matches no route gets an ordinary 404 from ASP.NET Core's routing, so YARP composes with the rest of your endpoints rather than intercepting everything unconditionally.

Getting Started: A Minimal Reverse Proxy#

The fastest path is configuration-driven: describe routes and clusters in appsettings.json, then wire up the proxy in three lines.

C#
// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

app.MapReverseProxy();

app.Run();
JSON
{
  "ReverseProxy": {
    "Routes": {
      "orders-route": {
        "ClusterId": "orders-cluster",
        "Match": { "Path": "/api/orders/{**catch-all}" }
      },
      "catalog-route": {
        "ClusterId": "catalog-cluster",
        "Match": { "Path": "/api/catalog/{**catch-all}" }
      }
    },
    "Clusters": {
      "orders-cluster": {
        "Destinations": {
          "orders-1": { "Address": "https://orders-svc-1.internal/" },
          "orders-2": { "Address": "https://orders-svc-2.internal/" }
        }
      },
      "catalog-cluster": {
        "Destinations": {
          "catalog-1": { "Address": "https://catalog-svc.internal/" }
        }
      }
    }
  }
}

LoadFromConfig also watches the configuration source, so updating appsettings.json (or whatever provider backs it β€” environment variables, Azure App Configuration, a key/value store) rebuilds routes and clusters without a restart.

Configuring Routes and Clusters in Code#

Config files suit routes that map cleanly onto a small, fairly static set of backends. For routes computed from a service registry, a database or another dynamic source, build the same route and cluster objects in code and hand them to YARP through IProxyConfigProvider, or use the in-memory config helper for simpler cases:

C#
var routes = new[]
{
    new RouteConfig
    {
        RouteId = "orders-route",
        ClusterId = "orders-cluster",
        Match = new RouteMatch { Path = "/api/orders/{**catch-all}" }
    }
};

var clusters = new[]
{
    new ClusterConfig
    {
        ClusterId = "orders-cluster",
        Destinations = new Dictionary<string, DestinationConfig>
        {
            ["orders-1"] = new() { Address = "https://orders-svc-1.internal/" }
        }
    }
};

builder.Services.AddReverseProxy().LoadFromMemory(routes, clusters);

LoadFromMemory is a snapshot; if destinations change at runtime β€” for example, from service discovery β€” call it again with fresh data, or implement IProxyConfigProvider to push updates through its change-notification support instead of rebuilding from scratch each time.

Transforms: Reshaping Requests and Responses#

Transforms change a request on the way out or a response on the way back, without your backend services needing to know a proxy is involved. Config-based transforms cover most cases:

JSON
"Routes": {
  "orders-route": {
    "ClusterId": "orders-cluster",
    "Match": { "Path": "/api/orders/{**catch-all}" },
    "Transforms": [
      { "PathRemovePrefix": "/api" },
      { "RequestHeader": "X-Gateway-Version", "Set": "1.4" },
      { "ResponseHeader": "Server", "Set": "", "When": "Always" }
    ]
  }
}

YARP automatically adds X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host so backends can see the original client and scheme even though the connection they see is from the proxy. For logic that cannot be expressed declaratively, register a code-based transform instead:

C#
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddTransforms(context =>
    {
        context.AddRequestTransform(async transformContext =>
        {
            var correlationId = transformContext.HttpContext.TraceIdentifier;
            transformContext.ProxyRequest.Headers.Add("X-Correlation-Id", correlationId);
            await ValueTask.CompletedTask;
        });
    });

Load Balancing Policies#

A cluster with more than one destination needs a policy for choosing between them, set per cluster with LoadBalancingPolicy:

PolicyBehavior
PowerOfTwoChoices (default)Samples two random destinations and picks the one with fewer active requests
RoundRobinCycles through destinations in order
LeastRequestsScans every destination and picks the one with the fewest active requests
RandomPicks a destination at random
FirstAlphabeticalAlways prefers the alphabetically first available destination; useful for simple active/standby failover

PowerOfTwoChoices is a good default because it avoids LeastRequests' cost of scanning every destination on every request while still steering away from an overloaded one; reach for LeastRequests only on small clusters where that scan is cheap, and FirstAlphabetical specifically for failover pairs rather than general load spreading.

Active and Passive Health Checks#

Active health checks poll a health endpoint on a schedule; passive health checks watch the outcome of real proxied requests. Both are configured per cluster and disabled by default:

JSON
"Clusters": {
  "orders-cluster": {
    "Destinations": { "orders-1": { "Address": "https://orders-svc-1.internal/" } },
    "HealthCheck": {
      "Active": {
        "Enabled": true,
        "Interval": "00:00:10",
        "Timeout": "00:00:05",
        "Policy": "ConsecutiveFailures",
        "Path": "/health"
      },
      "Passive": {
        "Enabled": true,
        "Policy": "TransportFailureRate",
        "ReactivationPeriod": "00:01:00"
      }
    }
  }
}

The built-in ConsecutiveFailuresHealthPolicy marks a destination unhealthy after enough consecutive probe failures and clears it once probes succeed again. The built-in TransportFailureRateHealthPolicy watches the failure rate of actual proxied traffic within a detection window and pulls a destination out immediately when it crosses the threshold; after ReactivationPeriod elapses, that destination's state resets to Unknown so it can be tried again rather than staying excluded forever. Use both together: active checks catch a destination that is down before it ever receives real traffic, passive checks catch one that is up but failing in a way its own health endpoint does not reflect.

Session Affinity#

Some backends keep per-connection or per-session state that only exists on the instance a client first hit β€” an in-memory cache, a WebSocket, an in-process session store. Session affinity ("sticky sessions") keeps a client routed to the same destination for as long as that state matters:

JSON
"Clusters": {
  "orders-cluster": {
    "SessionAffinity": {
      "Enabled": true,
      "Policy": "Cookie",
      "AffinityKeyName": ".Yarp.Affinity",
      "FailurePolicy": "Redistribute"
    },
    "Destinations": { "orders-1": { "Address": "https://orders-svc-1.internal/" } }
  }
}

The default HashCookie policy stores a hashed identifier in a cookie; Cookie encrypts it with ASP.NET Core Data Protection for stronger privacy, and CustomHeader stores it in a header instead, for non-browser clients that do not carry cookies. Affinity runs before load balancing: when a request arrives with a valid affinity key pointing at a healthy destination, that destination is used directly; otherwise the configured FailurePolicy either redistributes the request to a newly chosen (and now-affinitized) destination, or fails the request with a 503. Prefer designing backends to be stateless over relying on affinity where you can β€” it is a workaround for state that should not live only on one instance, not a substitute for statelessness.

Authentication and Authorization at the Gateway#

Because YARP is ASP.NET Core middleware, gateway-level authentication and authorization use exactly the same building blocks as any other endpoint: AddAuthentication, AddAuthorization and an AuthorizationPolicy referenced from a route.

C#
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddAuthorization(options =>
    options.AddPolicy("RequireOrdersScope", p => p.RequireClaim("scope", "orders.read")));

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.MapReverseProxy();
JSON
"Routes": {
  "orders-route": {
    "ClusterId": "orders-cluster",
    "AuthorizationPolicy": "RequireOrdersScope",
    "Match": { "Path": "/api/orders/{**catch-all}" }
  }
}

Centralizing authentication at the gateway means downstream services can trust that a request reaching them already passed authentication β€” but only if the network between the gateway and those services is not otherwise reachable, so backends still need their own authorization checks against whatever identity the gateway forwards, rather than trusting the network path alone.

Building a Backend-for-Frontend (BFF) with YARP#

A backend-for-frontend gateway terminates browser-facing authentication (typically cookie-based, via OpenID Connect) at the edge, and the browser never sees an access token at all β€” YARP holds it server-side and attaches it to proxied requests. This avoids storing bearer tokens in browser storage, which is the biggest source of token theft in single-page apps, at the cost of an extra hop through the gateway for every API call.

C#
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddTransforms(context =>
    {
        context.AddRequestTransform(async transformContext =>
        {
            var accessToken = await transformContext.HttpContext
                .GetTokenAsync("access_token");
            if (!string.IsNullOrEmpty(accessToken))
            {
                transformContext.ProxyRequest.Headers.Authorization =
                    new AuthenticationHeaderValue("Bearer", accessToken);
            }
        });
    });

The SPA authenticates against the gateway with a session cookie ([Authorize] protecting the proxied routes, as shown above), and the transform swaps that cookie for the real access token β€” retrieved from the authentication ticket, refreshed by the standard cookie/OIDC handshake β€” before forwarding to the API. Downstream services keep validating bearer tokens exactly as they would for any other caller; only the browser's side of the trust boundary changes.

Direct Forwarding with IHttpForwarder#

Sometimes you want to forward specific requests without routing, load balancing or affinity β€” a single fixed destination reachable from one endpoint. IHttpForwarder is the low-level building block YARP itself is built on, usable directly:

C#
var httpClient = new HttpMessageInvoker(new SocketsHttpHandler
{
    UseProxy = false,
    AllowAutoRedirect = false,
    AutomaticDecompression = DecompressionMethods.None
});

app.Map("/legacy/{**catch-all}", async (HttpContext context, IHttpForwarder forwarder) =>
{
    var error = await forwarder.SendAsync(
        context, "https://legacy-svc.internal/", httpClient, ForwarderRequestConfig.Empty);

    if (error != ForwarderError.None)
    {
        var feature = context.GetForwarderErrorFeature();
        logger.LogError(feature?.Exception, "Forwarding failed: {Error}", error);
    }
});

Use HttpMessageInvoker, not HttpClient β€” HttpClient buffers whole responses by default, which breaks streaming and adds latency that matters for a proxy forwarding large or long-lived responses such as gRPC or WebSocket traffic. Direct forwarding suits a handful of pass-through endpoints inside a larger app; reach for the full routing and cluster model once you need more than a couple of fixed destinations.

Rate Limiting at the Edge#

ASP.NET Core's built-in rate limiting middleware applies to YARP routes through the same RateLimiterPolicy route property used for authorization policies:

C#
builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("orders-policy", limiter =>
    {
        limiter.PermitLimit = 100;
        limiter.Window = TimeSpan.FromSeconds(10);
        limiter.QueueLimit = 20;
    });
});

var app = builder.Build();
app.UseRateLimiter();   // before MapReverseProxy
app.MapReverseProxy();
JSON
"Routes": {
  "orders-route": {
    "ClusterId": "orders-cluster",
    "RateLimiterPolicy": "orders-policy",
    "Match": { "Path": "/api/orders/{**catch-all}" }
  }
}

Enforcing limits at the gateway protects every backend behind it with one policy instead of duplicating limiter configuration into each service, and it rejects excess traffic before it ever reaches a backend's own resources. See the output caching and rate limiting guide for the limiter algorithms available and how to tune them.

Best Practices#

  • Keep gateway logic thin. Authentication, routing, rate limiting and cross-cutting headers belong at the gateway; business logic does not.
  • Enable both active and passive health checks on any cluster with more than one destination, rather than relying on the load balancer alone to route around a failure.
  • Prefer statelessness in backends over session affinity. Reach for affinity only when a backend genuinely cannot be made stateless in the time you have.
  • Version routes and clusters through the same review process as code, since a misconfigured route can silently send production traffic to the wrong destination.
  • Rate limit at the gateway for blanket protection, and again inside services for limits that depend on business logic the gateway does not see, such as per-tenant quotas.

Common Pitfalls#

  • Using HttpClient instead of HttpMessageInvoker with direct forwarding, which breaks streaming and adds needless buffering latency.
  • Forgetting UseRateLimiter() or UseAuthentication()/UseAuthorization() ordering relative to MapReverseProxy(), which silently lets requests through unchecked.
  • Trusting a forwarded identity without backend-side checks, treating the gateway's authentication as sufficient authorization everywhere downstream.
  • Enabling session affinity as a first fix for "random" bugs instead of finding the shared state that made the backend non-interchangeable in the first place.
  • Not watching TransportFailureRateHealthPolicy's reactivation behavior, which can silently keep sending a fraction of traffic to a flapping destination if the detection window is set too loosely.

YARP vs Azure API Management vs NGINX#

YARPAzure API ManagementNGINX
Deployment modelLibrary inside your own ASP.NET Core appFully managed Azure serviceStandalone process/config file
ExtensibilityC# code: transforms, policies, DIPolicy XML, built-in and custom policiesConfig DSL, Lua/JS modules
Where it runsAnywhere .NET runsAzure onlyAnywhere
Best fitGateways that need custom, code-level logic alongside .NET servicesAPI productization: developer portal, subscriptions, quotas, external partnersGeneral-purpose, high-throughput proxying and static content
Operational costYou build and run it, like any other serviceManaged, billed as an Azure resourceYou build and run it

Choose YARP when the gateway needs logic that is easier to write and test as C# than as a proxy's configuration language, and when keeping it in the same codebase and deployment pipeline as your services matters. Choose Azure API Management when you need a developer portal, external partner onboarding, subscription keys or usage analytics as a product, not just routing. Choose NGINX when the proxy is pure infrastructure with no custom per-request logic and you want a small, battle-tested footprint independent of any particular application stack. The three are not mutually exclusive β€” a common shape is YARP as an internal, code-driven gateway between services, behind Azure API Management or NGINX at the true edge.

Frequently Asked Questions#

Is YARP a standalone reverse proxy like NGINX?#

No. YARP is a .NET library and set of ASP.NET Core middleware that you host inside your own app; there is no separate YARP process or proprietary config format to install. That makes it a better fit when you want gateway logic written in C# and deployed alongside your other .NET services, rather than a general-purpose, application-agnostic proxy.

What is the default load balancing policy in YARP?#

PowerOfTwoChoices, which samples two random destinations and forwards to whichever currently has fewer active requests. It approximates the load-awareness of scanning every destination (LeastRequests) at a fraction of the cost, which is why it is the default rather than RoundRobin.

How do I make YARP stop sending traffic to a failing backend?#

Configure health checks on the cluster. Active health checks poll a health endpoint on an interval and exclude a destination after consecutive failures; passive health checks watch real proxied traffic and exclude a destination once its failure rate crosses a threshold, which catches failures an active probe might miss.

Can YARP handle authentication so my backend services don't have to?#

YARP can terminate authentication at the gateway using standard ASP.NET Core authentication and AuthorizationPolicy on routes, which is the basis of the backend-for-frontend pattern. Backend services should still validate whatever identity the gateway forwards, since trusting the gateway's authentication alone assumes the network between gateway and backend is fully trusted.

When should I use direct forwarding instead of the full routing model?#

Use IHttpForwarder directly for a small number of fixed pass-through endpoints where you do not need routing, load balancing or health checks β€” for example, proxying one legacy service from inside a larger app. Once you have multiple destinations, need load balancing, or want configuration-driven routes, use AddReverseProxy() with routes and clusters instead.

Summary#

  • YARP is a .NET library, not a standalone process: routes match requests to clusters, and clusters hold destinations, a load balancing policy, health checks and optional session affinity.
  • Configure routes and clusters from appsettings.json for static topologies, or in code/IProxyConfigProvider for dynamic ones.
  • PowerOfTwoChoices is the default load balancing policy; combine active and passive health checks so both down and misbehaving destinations get excluded.
  • YARP's transform pipeline supports gateway-level authentication, the BFF pattern, and edge rate limiting through the same primitives as any ASP.NET Core app.
  • Reach for IHttpForwarder for simple pass-through forwarding, and the full routing model once you have more than a couple of destinations.

Further Reading#