Back to blog
Backend Systemsintermediate

YARP — Build an API Gateway in 50 Lines of C#

Use YARP (Yet Another Reverse Proxy) as an API gateway: route config, code-based setup, header transforms, path rewriting, auth at the gateway, rate limiting, load balancing, and health checks.

LearnixoApril 15, 20264 min read
.NETC#YARPAPI GatewayReverse ProxyASP.NET CoreLoad Balancing
Share:𝕏

What an API Gateway Does

An API gateway is a single entry point in front of multiple backend services. It handles:

  • Routing/orders/* goes to the Orders service, /products/* goes to Products
  • Authentication — validate JWT once at the gateway; backends trust internal traffic
  • Rate limiting — protect all backends without each one implementing it
  • Path rewriting — strip /api/v1 prefix before forwarding
  • Load balancing — round-robin between multiple instances

Without a gateway, every service reimplements auth, rate limiting, and CORS. With one, backends focus purely on business logic.

Install YARP

dotnet new web -n ApiGateway
cd ApiGateway
dotnet add package Yarp.ReverseProxy

Minimal Gateway in ~50 Lines

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

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

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opt =>
    {
        opt.Authority = builder.Configuration["Auth:Authority"];
        opt.Audience = builder.Configuration["Auth:Audience"];
    });

builder.Services.AddRateLimiter(o =>
{
    o.AddFixedWindowLimiter("auth_endpoints", cfg =>
    {
        cfg.Window = TimeSpan.FromMinutes(1);
        cfg.PermitLimit = 10;
        cfg.QueueLimit = 0;
    });
    o.AddFixedWindowLimiter("general", cfg =>
    {
        cfg.Window = TimeSpan.FromSeconds(10);
        cfg.PermitLimit = 100;
    });
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapReverseProxy();

app.Run();

Route + Cluster Config in appsettings.json

JSON
{
  "ReverseProxy": {
    "Routes": {
      "orders-route": {
        "ClusterId": "orders-cluster",
        "AuthorizationPolicy": "default",
        "RateLimiterPolicy": "general",
        "Match": { "Path": "/api/orders/{**catch-all}" },
        "Transforms": [
          { "PathPattern": "/api/orders/{**catch-all}" }
        ]
      },
      "products-route": {
        "ClusterId": "products-cluster",
        "Match": { "Path": "/api/products/{**catch-all}" }
      },
      "auth-route": {
        "ClusterId": "auth-cluster",
        "RateLimiterPolicy": "auth_endpoints",
        "Match": { "Path": "/auth/{**catch-all}" }
      }
    },
    "Clusters": {
      "orders-cluster": {
        "LoadBalancingPolicy": "RoundRobin",
        "HealthCheck": {
          "Active": {
            "Enabled": true,
            "Interval": "00:00:10",
            "Timeout": "00:00:05",
            "Policy": "ConsecutiveFailures",
            "Path": "/health"
          }
        },
        "Destinations": {
          "orders-1": { "Address": "http://orders-service-1:8080/" },
          "orders-2": { "Address": "http://orders-service-2:8080/" }
        }
      },
      "products-cluster": {
        "Destinations": {
          "products-1": { "Address": "http://products-service:8080/" }
        }
      },
      "auth-cluster": {
        "Destinations": {
          "auth-1": { "Address": "http://auth-service:8080/" }
        }
      }
    }
  }
}

Code-Based Config With IReverseProxyBuilder

For dynamic routing (routes from a database, service discovery), use code:

C#
builder.Services
    .AddReverseProxy()
    .LoadFromMemory(GetRoutes(), GetClusters());

static RouteConfig[] GetRoutes() =>
[
    new RouteConfig
    {
        RouteId = "orders-route",
        ClusterId = "orders-cluster",
        Match = new RouteMatch { Path = "/api/orders/{**catch-all}" },
        AuthorizationPolicy = "default"
    }
];

static ClusterConfig[] GetClusters() =>
[
    new ClusterConfig
    {
        ClusterId = "orders-cluster",
        LoadBalancingPolicy = LoadBalancingPolicies.RoundRobin,
        Destinations = new Dictionary<string, DestinationConfig>
        {
            ["orders-1"] = new DestinationConfig { Address = "http://orders:8080/" }
        }
    }
];

To update routes at runtime without restart:

C#
app.MapPost("/admin/reload", async (InMemoryConfigProvider provider) =>
{
    provider.Update(LoadRoutesFromDb(), LoadClustersFromDb());
    return Results.Ok("Routes reloaded");
}).RequireAuthorization("admin");

Transforms — Headers, Path Rewriting, Prefix Strip

JSON
"Transforms": [
  // Strip /api/v1 prefix before forwarding
  { "PathRemovePrefix": "/api/v1" },

  // Rewrite /gateway/orders/123 -> /orders/123
  { "PathPattern": "/orders/{**catch-all}" },

  // Add a header the downstream service trusts
  { "RequestHeader": "X-Forwarded-From", "Set": "api-gateway" },

  // Forward the original host
  { "RequestHeaderOriginalHost": "true" },

  // Remove a header before forwarding
  { "RequestHeaderRemove": "X-Internal-Debug" }
]

In code, implement ITransformProvider for dynamic transforms:

C#
public class AddCorrelationIdTransform : ITransformProvider
{
    public void ValidateRoute(TransformRouteValidationContext ctx) { }
    public void ValidateCluster(TransformClusterValidationContext ctx) { }

    public void Apply(TransformBuilderContext ctx)
    {
        ctx.AddRequestTransform(async reqCtx =>
        {
            var id = reqCtx.HttpContext.TraceIdentifier;
            reqCtx.ProxyRequest.Headers.TryAddWithoutValidation("X-Correlation-Id", id);
        });
    }
}

// Register
builder.Services.AddSingleton<ITransformProvider, AddCorrelationIdTransform>();

Authentication at the Gateway Level

Validate the JWT once at the gateway. Downstream services receive the verified claims as trusted headers.

C#
// After UseAuthentication(), add a transform that passes claims downstream
public class ForwardClaimsTransform : ITransformProvider
{
    public void Apply(TransformBuilderContext ctx)
    {
        ctx.AddRequestTransform(reqCtx =>
        {
            var user = reqCtx.HttpContext.User;
            if (user.Identity?.IsAuthenticated == true)
            {
                var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
                var roles = string.Join(",", user.FindAll(ClaimTypes.Role).Select(c => c.Value));
                reqCtx.ProxyRequest.Headers.TryAddWithoutValidation("X-User-Id", userId);
                reqCtx.ProxyRequest.Headers.TryAddWithoutValidation("X-User-Roles", roles);
            }
            return ValueTask.CompletedTask;
        });
    }
    // ...
}

Downstream services read X-User-Id without re-validating the JWT — they trust the gateway. Only accept these headers from the gateway's internal network (not from external clients).

Load Balancing Policies

YARP supports several built-in policies:

JSON
"LoadBalancingPolicy": "RoundRobin"      // default, equal distribution
"LoadBalancingPolicy": "LeastRequests"   // fewest in-flight requests
"LoadBalancingPolicy": "Random"          // random destination
"LoadBalancingPolicy": "PowerOfTwoChoices" // pick 2 random, use least loaded

Health Checks for Destinations

YARP's active health check pings each destination's /health endpoint and removes unhealthy ones from the pool:

C#
// In the downstream service (Orders API)
app.MapHealthChecks("/health"); // returns 200 OK when healthy
JSON
"HealthCheck": {
  "Active": {
    "Enabled": true,
    "Interval": "00:00:15",
    "Timeout": "00:00:05",
    "Policy": "ConsecutiveFailures",
    "Path": "/health"
  },
  "Passive": {
    "Enabled": true,
    "Policy": "TransportFailureRate",
    "ReactivationPeriod": "00:02:00"
  }
}

ConsecutiveFailures removes a destination after N failures. TransportFailureRate removes it when error rate exceeds a threshold.

Rate Limiting Per Route

C#
builder.Services.AddRateLimiter(o =>
{
    o.AddSlidingWindowLimiter("per_user", cfg =>
    {
        cfg.Window = TimeSpan.FromMinutes(1);
        cfg.PermitLimit = 60;
        cfg.SegmentsPerWindow = 6;
        // Partition by user ID from the claim forwarded by auth
    });
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});

Assign the policy in the route config: "RateLimiterPolicy": "per_user". YARP applies it before proxying.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.