When a microservices system grows past a handful of services, client applications run into a real problem. A mobile app or a web frontend has to know the address of every single service it needs to call, handle authentication separately for each one, and deal with the fact that services move, scale, or get replaced over time. This gets messy fast, and it also leaks internal architecture details to clients that have no business knowing them.
An API gateway solves this by sitting in front of the microservices and giving clients one address to talk to. The gateway takes care of routing, security, and cross-cutting concerns, so individual services stay focused on business logic. In this article, I will walk through building an API gateway using YARP, the reverse proxy library from Microsoft, and cover routing, authentication, and rate limiting.
API Gateway vs Reverse Proxy: What Is the Difference
People use these two terms interchangeably, but they are not quite the same thing. A reverse proxy is the more general concept: it sits between clients and servers, accepts incoming requests, and forwards them to the right backend server. Clients never talk to the backend directly, they only ever see the proxy.
Reverse proxies are commonly used for load balancing across multiple instances of a service, caching responses to reduce backend load, terminating SSL so individual services do not each need their own certificate, and general security hardening at the network edge.

An API gateway is a reverse proxy built specifically for managing APIs. On top of the routing and load balancing a plain reverse proxy gives you, an API gateway typically adds request and response transformation, authentication and authorization at the edge, rate limiting per client or per route, and centralized monitoring and logging across all the services behind it. In practice, when people say API gateway in a microservices context, they mean a reverse proxy with these extra responsibilities baked in.
Installing and Configuring YARP
YARP, which stands for Yet Another Reverse Proxy, is Microsoft’s open source reverse proxy toolkit built on ASP.NET Core. Because it runs as a regular ASP.NET Core application, it integrates cleanly with the rest of the .NET ecosystem: the same middleware pipeline, the same configuration system, the same dependency injection container you already use in your services.
Start by adding the YARP package to a new ASP.NET Core project that will act as your gateway.
Install-Package Yarp.ReverseProxy
This pulls in the reverse proxy middleware and configuration binding support. Next, wire it into the application with three calls: AddReverseProxy registers the required services, LoadFromConfig binds the proxy configuration from appsettings.json (or any configuration provider you use), and MapReverseProxy adds the proxy middleware to the request pipeline.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();
This is the entire application setup. There is no controller, no manual routing code, everything about how requests get forwarded lives in configuration. That is one of the nicer aspects of YARP: your gateway behavior is data, not code, which makes it much easier to change routing rules without redeploying the gateway.
YARP configuration revolves around two concepts: Routes and Clusters. A route defines a request pattern to match, for example a URL path prefix, and points to a cluster. A cluster represents one logical backend service and lists one or more destination addresses for it.
{
"ReverseProxy": {
"Routes": {
"ROUTE_NAME": {
"ClusterId": "CLUSTER_NAME",
"Match": {
"Path": "{**catch-all}"
}
}
},
"Clusters": {
"CLUSTER_NAME": {
"Destinations": {
"destination1": {
"Address": "https://www.milanjovanovic.tech/"
}
}
}
}
}
}
The {**catch-all} pattern matches every path and forwards it to the single destination configured in the cluster. This is the simplest possible configuration, useful for a quick test, but a real gateway will define multiple routes, one per backend service, each matching a distinct URL prefix.
Routing to Multiple Microservices
A realistic setup has several services behind the gateway, each reachable through its own path prefix. Here is a configuration for two services, a Users API and a Products API, where requests to /users-service/* get routed to the users cluster and requests to /products-service/* go to the products cluster.
{
"ReverseProxy": {
"Routes": {
"users-route": {
"ClusterId": "users-cluster",
"Match": {
"Path": "/users-service/{**catch-all}"
},
"Transforms": [{ "PathPattern": "{**catch-all}" }]
},
"products-route": {
"ClusterId": "products-cluster",
"Match": {
"Path": "/products-service/{**catch-all}"
},
"Transforms": [{ "PathPattern": "{**catch-all}" }]
}
},
"Clusters": {
"users-cluster": {
"Destinations": {
"destination1": {
"Address": "https://localhost:5201/"
}
}
},
"products-cluster": {
"Destinations": {
"destination1": {
"Address": "https://localhost:5101/"
}
}
}
}
}
}
The Transforms section here is doing important work. Without it, YARP would forward the full incoming path, including the /users-service prefix, to the destination service, and the destination service would need to know about that prefix to route the request correctly. The PathPattern transform strips the prefix before forwarding, so the Users API only ever sees clean paths like /users, with no knowledge that a gateway sits in front of it. This keeps the individual services decoupled from how the gateway exposes them externally, which matters if you ever want to change the public path prefix without touching the downstream service.
At this point the gateway is functional: a client hitting /users-service/users or /products-service/products through the gateway gets transparently routed to the right backend. What is missing is anything resembling security or protection against abuse, which is where most of the real engineering effort goes.
Adding Authentication at the Gateway
Centralizing authentication at the gateway is one of the strongest arguments for having a gateway in the first place. Instead of every microservice implementing its own token validation, you validate once at the edge and let authenticated requests through. YARP does not reinvent authentication, it integrates directly with the standard ASP.NET Core authentication and authorization middleware you already use in any secured API.
First, define an authorization policy the same way you would in any ASP.NET Core application.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("authenticated", policy =>
policy.RequireAuthenticatedUser());
});
Then register the authentication and authorization middleware in the pipeline. Ordering matters here: UseAuthentication and UseAuthorization must run before MapReverseProxy, otherwise the proxy will forward requests before the identity of the caller has even been established.
app.UseAuthentication();
app.UseAuthorization();
app.MapReverseProxy();
With the middleware in place, attach the policy to a specific route using the AuthorizationPolicy property in configuration. Only requests satisfying that policy get proxied through, everything else is rejected at the gateway before it ever reaches the Users API.
"users-route": {
"ClusterId": "users-cluster",
"AuthorizationPolicy": "authenticated",
"Match": {
"Path": "/users-service/{**catch-all}"
},
"Transforms": [
{ "PathPattern": "{**catch-all}" }
]
}
One detail worth calling out for production use: YARP forwards cookies and bearer tokens through to the downstream services by default. This matters because individual microservices often still need to know who the caller is, for authorization checks specific to that service, or for auditing. Validating identity at the gateway does not mean the downstream services stop caring about identity, it just means they no longer need to do the heavy lifting of validating the token signature and expiry themselves, assuming you trust the network path between gateway and services.
Adding Rate Limiting
Rate limiting at the gateway protects your backend services from being overwhelmed, whether from a genuine traffic spike, a buggy client retrying aggressively, or an actual abuse attempt. Doing it once at the gateway is far simpler than implementing rate limiting logic separately in every service. YARP builds on the native rate limiting middleware that shipped in .NET 7, so there is nothing extra to install.
Define a rate limiter policy in the same way you would for any ASP.NET Core API. A fixed window limiter is the simplest strategy: it allows a set number of requests within a time window, then rejects the rest until the window resets.
builder.Services.AddRateLimiter(rateLimiterOptions =>
{
rateLimiterOptions.AddFixedWindowLimiter("fixed", options =>
{
options.Window = TimeSpan.FromSeconds(10);
options.PermitLimit = 5;
});
});
This example permits 5 requests every 10 seconds per limiter instance. It is deliberately strict for demonstration purposes, in a real system you would tune the window and permit count against your actual expected traffic patterns, and you would probably also look at a sliding window or token bucket limiter to smooth out bursts rather than a hard cliff at the window boundary.
Register the rate limiter middleware before MapReverseProxy, the same ordering rule that applied to authentication.
app.UseRateLimiter();
app.MapReverseProxy();
Finally, apply the policy to a route through the RateLimiterPolicy property.
"products-route": {
"ClusterId": "products-cluster",
"RateLimiterPolicy": "fixed",
"Match": {
"Path": "/products-service/{**catch-all}"
},
"Transforms": [
{ "PathPattern": "{**catch-all}" }
]
}
Each route can have its own rate limiter policy, so you can be stricter on expensive endpoints and more lenient on cheap read-only ones. This per-route granularity is one of the practical advantages of doing rate limiting at the gateway instead of at the individual service level, where coordinating different limits across services becomes harder to reason about.
Production Considerations and Trade-offs
The configuration in this article gets a gateway running, but a few things need attention before this goes anywhere near production. The gateway becomes a single point of failure for your entire system, so it needs to run as multiple instances behind its own load balancer, with health checks configured on each cluster so YARP stops routing to a backend instance that has gone unhealthy.
A gateway also adds one extra network hop to every request, which is a real latency cost, usually small but not zero, and worth measuring rather than assuming away. If your services are chatty with each other internally, you generally do not want service-to-service calls going back out through the gateway, that traffic should stay on the internal network and only external client traffic should be gateway-routed.
On the configuration management side, hardcoding destination addresses in appsettings.json works for a demo but not for a system where service instances scale up and down dynamically. In a containerized deployment on Kubernetes or Azure Container Apps, you would typically pair YARP with a service discovery mechanism, or use YARP’s support for pulling destinations from Kubernetes directly, rather than maintaining a static list of addresses by hand.
It is also worth being honest about when a gateway is not needed. If you have two or three services and a single client application, the overhead of standing up and operating a gateway may not be worth it yet. Introduce it when the number of services and the number of distinct client types grows enough that the coordination problem it solves actually exists in your system.
Wrapping Up
YARP gives you a solid, code-first way to build an API gateway on top of the ASP.NET Core stack you already know, without pulling in a separate infrastructure component like Kong or Ocelot unless you have a specific reason to. Routing, authentication, and rate limiting all plug into the same configuration and middleware pipeline patterns familiar from regular ASP.NET Core development, which keeps the learning curve short for a .NET team.
What is covered here is the starting point. Production-grade gateways usually layer on request and response transformation for versioning or backward compatibility, distributed tracing and correlation IDs so you can follow a request across the gateway and every downstream service it touches, and a deployment topology that treats the gateway itself as a scaled, independently deployable service rather than a fixed piece of infrastructure.
Leave a Reply