YARP Deep Dive — API Gateway with .NET Reverse Proxy
Build a production API gateway with YARP (Yet Another Reverse Proxy): routing, transforms, load balancing, auth offloading, rate limiting, health checks, and request forwarding patterns.
YARP Deep Dive — API Gateway with .NET Reverse Proxy
YARP (Yet Another Reverse Proxy) is Microsoft's high-performance reverse proxy library for .NET. It runs inside an ASP.NET Core application, giving you full programmatic control over routing, transforms, load balancing, and authentication offloading.
Why YARP over nginx/Traefik?
nginx / Traefik:
✓ Battle-tested, zero-code configuration
✓ Very high throughput
✗ Lua scripts or plugins for business logic
✗ No .NET DI — can't inject your services into routing rules
YARP:
✓ C# code for all routing logic — inject any .NET service
✓ Programmatic route and cluster management (update at runtime)
✓ Tight OpenTelemetry integration
✓ JWT validation, claims transformation — no separate auth sidecar
✗ Not as fast as nginx at extreme scale (still very fast for most use cases)
→ Right choice when routing logic requires business knowledge (tenant lookup,
feature flags, A/B testing) that can't be expressed in config files.Step 1: Install and Configure
<PackageReference Include="Yarp.ReverseProxy" Version="2.*" />// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapReverseProxy(); // YARP handles all proxied requests
app.Run();// appsettings.json — static configuration
{
"ReverseProxy": {
"Routes": {
"orders-route": {
"ClusterId": "orders-cluster",
"Match": { "Path": "/api/orders/{**catch-all}" },
"Transforms": [
{ "PathRemovePrefix": "/api/orders" },
{ "RequestHeader": "X-Gateway", "Append": "yarp-gateway" }
]
},
"payments-route": {
"ClusterId": "payments-cluster",
"Match": {
"Path": "/api/payments/{**catch-all}",
"Methods": ["GET", "POST"]
},
"AuthorizationPolicy": "default"
}
},
"Clusters": {
"orders-cluster": {
"LoadBalancingPolicy": "RoundRobin",
"Destinations": {
"orders-1": { "Address": "http://orders-service:8080/" },
"orders-2": { "Address": "http://orders-service-2:8080/" }
},
"HealthCheck": {
"Active": {
"Enabled": true,
"Interval": "00:00:10",
"Timeout": "00:00:05",
"Path": "/health"
}
}
},
"payments-cluster": {
"Destinations": {
"payments-1": { "Address": "https://payments-service:8443/" }
},
"HttpClient": {
"SslProtocols": "Tls13"
}
}
}
}
}Step 2: Request Transforms
// Code-based transforms — full C# power for request/response modification
builder.Services
.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(ctx =>
{
// Inject tenant ID from JWT claim into upstream header
ctx.AddRequestTransform(async transformCtx =>
{
var tenantId = transformCtx.HttpContext.User.FindFirst("tenant_id")?.Value;
if (tenantId is not null)
transformCtx.ProxyRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenantId);
});
// Strip the "Authorization" header before forwarding to internal services
// (they rely on the X-Tenant-Id header instead)
ctx.AddRequestHeader("X-Gateway-Version", "2.1", append: false);
// Modify response — add CORS headers centrally
ctx.AddResponseTransform(async transformCtx =>
{
transformCtx.HttpContext.Response.Headers["X-Forwarded-By"] = "yarp";
await ValueTask.CompletedTask;
});
});Step 3: Dynamic Routing from Database
// Load routes and clusters from a database — update without restart
public class DatabaseProxyConfigProvider(
IServiceScopeFactory scopeFactory,
IProxyConfigChangeToken changeToken)
: IProxyConfigProvider
{
public IProxyConfig GetConfig()
=> new DatabaseProxyConfig(scopeFactory, changeToken);
}
public class DatabaseProxyConfig(
IServiceScopeFactory scopeFactory,
IProxyConfigChangeToken changeToken)
: IProxyConfig
{
public IReadOnlyList<RouteConfig> Routes { get; } = LoadRoutes(scopeFactory);
public IReadOnlyList<ClusterConfig> Clusters { get; } = LoadClusters(scopeFactory);
public IChangeToken ChangeToken { get; } = changeToken;
private static IReadOnlyList<RouteConfig> LoadRoutes(IServiceScopeFactory factory)
{
using var scope = factory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
return db.GatewayRoutes.AsNoTracking().ToList().Select(r => new RouteConfig
{
RouteId = r.RouteId,
ClusterId = r.ClusterId,
Match = new RouteMatch { Path = r.PathPattern },
}).ToList();
}
private static IReadOnlyList<ClusterConfig> LoadClusters(IServiceScopeFactory factory)
{
using var scope = factory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
return db.GatewayClusters.Include(c => c.Destinations).AsNoTracking().ToList()
.Select(c => new ClusterConfig
{
ClusterId = c.ClusterId,
LoadBalancingPolicy = c.LoadBalancingPolicy,
Destinations = c.Destinations.ToDictionary(
d => d.Name,
d => new DestinationConfig { Address = d.Address }),
}).ToList();
}
}
// Trigger config reload (e.g. when an admin updates routing)
public class GatewayAdminController(IProxyStateLookup proxy) : ControllerBase
{
[HttpPost("admin/routes/reload")]
[Authorize(Roles = "Admin")]
public IActionResult ReloadRoutes()
{
// Signal the change token — YARP reloads config
// (implement IProxyConfigChangeToken with CancellationChangeToken)
return NoContent();
}
}Step 4: Authentication Offloading
// Validate JWT at the gateway — upstream services trust the forwarded headers
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opts =>
{
opts.Authority = "https://auth.example.com";
opts.Audience = "api";
opts.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
};
});
builder.Services.AddAuthorization(opts =>
{
// Default policy: must be authenticated
opts.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
// Route-level auth in config
// "AuthorizationPolicy": "default" → applies the default policy
// "AuthorizationPolicy": "anonymous" → allows unauthenticated
// In transforms: forward validated identity to upstream
builder.Services
.AddReverseProxy()
.AddTransforms(ctx =>
{
ctx.AddRequestTransform(async t =>
{
var user = t.HttpContext.User;
if (user.Identity?.IsAuthenticated == true)
{
t.ProxyRequest.Headers.TryAddWithoutValidation(
"X-User-Id", user.FindFirst("sub")?.Value ?? "");
t.ProxyRequest.Headers.TryAddWithoutValidation(
"X-User-Roles", string.Join(",", user.Claims
.Where(c => c.Type == "roles")
.Select(c => c.Value)));
}
await ValueTask.CompletedTask;
});
});Step 5: Rate Limiting at the Gateway
// Apply rate limiting before requests reach upstream services
builder.Services.AddRateLimiter(opts =>
{
opts.AddFixedWindowLimiter("per-user", o =>
{
o.Window = TimeSpan.FromMinutes(1);
o.PermitLimit = 100;
o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
o.QueueLimit = 10;
});
opts.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
opts.OnRejected = async (ctx, token) =>
{
ctx.HttpContext.Response.Headers.RetryAfter = "60";
await ctx.HttpContext.Response.WriteAsJsonAsync(
new { error = "Rate limit exceeded. Try again in 60 seconds." }, token);
};
});
app.UseRateLimiter();
// Wire rate limiter to YARP routes programmatically
app.MapReverseProxy(pipeline =>
{
pipeline.UseRateLimiter("per-user");
pipeline.UseLoadBalancing();
pipeline.UseSessionAffinity();
pipeline.UseHealthChecks();
pipeline.UsePassiveHealthChecks();
});Step 6: Load Balancing Policies
YARP load balancing policies:
RoundRobin — rotate through destinations in order
Random — random destination per request
PowerOfTwoChoices — pick 2 random, send to the less busy one (best for uneven load)
LeastRequests — always pick destination with fewest active requests
First — always pick the first healthy destination
SessionAffinity — sticky session (route same client to same destination)// Session affinity — sticky sessions for stateful services
{
"Clusters": {
"stateful-cluster": {
"LoadBalancingPolicy": "RoundRobin",
"SessionAffinity": {
"Enabled": true,
"Policy": "Cookie",
"AffinityKeyName": "X-Gateway-Affinity"
}
}
}
}Interview Answer
"YARP is Microsoft's reverse proxy library for .NET — it runs as middleware in ASP.NET Core, giving you C# code for all routing and transformation logic. Configuration can be static (appsettings.json), dynamic (loaded from a database via IProxyConfigProvider — update routes without restart), or hybrid. Transforms are the key feature: inject tenant ID from JWT claims into upstream headers, strip the Authorization header before forwarding, add custom headers centrally. Authentication offloading: validate JWT at the gateway with AddJwtBearer, then forward the validated identity (user ID, roles) as trusted headers — upstream services don't need their own auth middleware. Rate limiting integrates via ASP.NET Core's built-in rate limiter — apply it per-user or per-IP before requests reach any upstream. Load balancing policies include RoundRobin, LeastRequests, and PowerOfTwoChoices — use passive health checks to automatically mark unhealthy destinations and active health checks to recover them. YARP is the right choice when routing logic requires business knowledge (tenant routing, feature flags, A/B testing) that nginx config can't express."
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.