.NET & C# Development · Lesson 90 of 92
YARP Gateway — Route, Load Balance & Authenticate at the Edge
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/v1prefix 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.ReverseProxyMinimal Gateway in ~50 Lines
// 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
{
"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:
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:
app.MapPost("/admin/reload", async (InMemoryConfigProvider provider) =>
{
provider.Update(LoadRoutesFromDb(), LoadClustersFromDb());
return Results.Ok("Routes reloaded");
}).RequireAuthorization("admin");Transforms — Headers, Path Rewriting, Prefix Strip
"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:
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.
// 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:
"LoadBalancingPolicy": "RoundRobin" // default, equal distribution
"LoadBalancingPolicy": "LeastRequests" // fewest in-flight requests
"LoadBalancingPolicy": "Random" // random destination
"LoadBalancingPolicy": "PowerOfTwoChoices" // pick 2 random, use least loadedHealth Checks for Destinations
YARP's active health check pings each destination's /health endpoint and removes unhealthy ones from the pool:
// In the downstream service (Orders API)
app.MapHealthChecks("/health"); // returns 200 OK when healthy"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
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.