Skip to content

YARP Reverse Proxy

YARP (Yet Another Reverse Proxy) is Microsoft’s open-source, high-performance reverse proxy library built on ASP.NET Core. Granit.Bff uses YARP to proxy API calls from the SPA to backend services, injecting Bearer tokens from the server-side session store.

Key YARP features used by Granit.Bff:

FeatureHow Granit.Bff uses it
Request transformsBffTokenInjectionTransform and BffCsrfValidationTransform
Configuration-based routingRoutes and clusters from appsettings.json
Route metadataGranit.Bff.RequireAuth to mark authenticated routes
StreamingLarge responses streamed without buffering
WebSocket proxyingNative WebSocket upgrade support

YARP routes are configured in the ReverseProxy section of appsettings.json. Each route defines a URL pattern and maps it to a cluster (backend service).

{
"ReverseProxy": {
"Routes": {
"api": {
"ClusterId": "backend-api",
"Match": {
"Path": "/api/{**catch-all}"
},
"Metadata": {
"Granit.Bff.RequireAuth": "true"
}
}
},
"Clusters": {
"backend-api": {
"Destinations": {
"primary": {
"Address": "https://api.example.com"
}
}
}
}
}
}

This metadata key controls which routes trigger the BFF security pipeline. When set to "true", the route activates both transforms:

flowchart TD
    A[Incoming YARP request]
    A --> B{Route has<br/>Granit.Bff.RequireAuth = true?}

    B -->|No| C[Forward directly<br/>No token injection<br/>No CSRF validation]

    B -->|Yes| D[BffCsrfValidationTransform]
    D --> E{Mutating method?<br/>POST/PUT/DELETE/PATCH}
    E -->|No| G
    E -->|Yes| F{X-CSRF-Token valid?}
    F -->|No| H["403 Forbidden"]
    F -->|Yes| G[BffTokenInjectionTransform]

    G --> I{Session cookie present?}
    I -->|No| J["401 Unauthorized"]
    I -->|Yes| K{Token in cache?}
    K -->|No| J
    K -->|Yes| L{Token expiring soon?}
    L -->|No| M[Inject Bearer token]
    L -->|Yes| N[Silent refresh]
    N -->|Success| M
    N -->|Failure| J
    M --> O[Forward to upstream]

    style H fill:#e74c3c,color:#fff
    style J fill:#e74c3c,color:#fff
    style O fill:#2ecc71,color:#fff
    style C fill:#3498db,color:#fff

Routes without the Granit.Bff.RequireAuth metadata are forwarded without any authentication. Use this for public endpoints:

{
"ReverseProxy": {
"Routes": {
"public-api": {
"ClusterId": "backend-api",
"Match": {
"Path": "/public/{**catch-all}"
}
},
"health": {
"ClusterId": "backend-api",
"Match": {
"Path": "/health"
}
}
}
}
}

For routes where authentication is optional (e.g., a product listing that shows different data for authenticated users), you can omit Granit.Bff.RequireAuth and handle the Authorization header presence on the backend side:

{
"ReverseProxy": {
"Routes": {
"products": {
"ClusterId": "backend-api",
"Match": {
"Path": "/api/products/{**catch-all}"
}
}
}
}
}

Without RequireAuth, the YARP transforms will not inject tokens. The request is forwarded as-is, and the backend can check for the presence of an Authorization header.

The BffTokenInjectionTransform is the core of the BFF proxy. It runs as a YARP request transform on every route with Granit.Bff.RequireAuth = true.

sequenceDiagram
    participant SPA as SPA (Browser)
    participant CSRF as BffCsrfValidationTransform
    participant TI as BffTokenInjectionTransform
    participant Cache as Redis Cache
    participant IdP as Identity Provider
    participant API as Backend API

    SPA->>CSRF: Request with cookie + X-CSRF-Token

    Note over CSRF: Step 1: CSRF Validation<br/>(POST/PUT/DELETE/PATCH only)

    CSRF->>CSRF: Extract sessionId from cookie
    CSRF->>CSRF: Validate X-CSRF-Token HMAC

    CSRF->>TI: Request continues

    Note over TI: Step 2: Token Injection

    TI->>Cache: Get bff:session:{sessionId}
    Cache->>TI: BffTokenSet

    alt Token not expiring
        TI->>TI: Set Authorization: Bearer {accessToken}
    else Token expiring within grace period
        TI->>IdP: POST /connect/token (refresh_token grant)
        IdP->>TI: New tokens
        TI->>Cache: Update bff:session:{sessionId}
        TI->>TI: Set Authorization: Bearer {new_accessToken}
        TI->>TI: Set X-Bff-Session-Refreshed: true
    end

    TI->>API: Forwarded request with Bearer token
    API->>SPA: Response

Both transforms are registered in AddGranitBffYarp():

builder.Services
.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(context =>
{
// CSRF validation runs first (blocks 403 before token injection)
context.RequestTransforms.Add(
context.Services.GetRequiredService<BffCsrfValidationTransform>());
// Token injection + silent refresh
context.RequestTransforms.Add(
context.Services.GetRequiredService<BffTokenInjectionTransform>());
});

The order matters: CSRF validation before token injection. If the CSRF token is invalid, the request is rejected with 403 before any token lookup or refresh occurs. This prevents unnecessary cache reads and IdP calls.

A single BFF can proxy to multiple backend services. Define multiple clusters and route patterns:

{
"ReverseProxy": {
"Routes": {
"orders-api": {
"ClusterId": "orders-service",
"Match": {
"Path": "/api/orders/{**catch-all}"
},
"Metadata": {
"Granit.Bff.RequireAuth": "true"
}
},
"products-api": {
"ClusterId": "products-service",
"Match": {
"Path": "/api/products/{**catch-all}"
},
"Metadata": {
"Granit.Bff.RequireAuth": "true"
}
},
"notifications-ws": {
"ClusterId": "notifications-service",
"Match": {
"Path": "/ws/notifications/{**catch-all}"
},
"Metadata": {
"Granit.Bff.RequireAuth": "true"
}
},
"public-catalog": {
"ClusterId": "products-service",
"Match": {
"Path": "/public/catalog/{**catch-all}"
}
}
},
"Clusters": {
"orders-service": {
"Destinations": {
"primary": {
"Address": "https://orders.internal:5001"
}
}
},
"products-service": {
"Destinations": {
"primary": {
"Address": "https://products.internal:5002"
}
}
},
"notifications-service": {
"Destinations": {
"primary": {
"Address": "https://notifications.internal:5003"
}
}
}
}
}
}
graph LR
    SPA["SPA (Browser)"]
    BFF["BFF + YARP"]
    ORDERS["Orders Service<br/>/api/orders/*"]
    PRODUCTS["Products Service<br/>/api/products/*<br/>/public/catalog/*"]
    NOTIF["Notifications Service<br/>/ws/notifications/*"]

    SPA -->|Cookie| BFF
    BFF -->|"Bearer token<br/>(injected)"| ORDERS
    BFF -->|"Bearer token<br/>(injected)"| PRODUCTS
    BFF -->|"Bearer token<br/>(WebSocket upgrade)"| NOTIF

    style BFF fill:#4a9eff,color:#fff,stroke-width:2px

All routes share the same BFF session and token store. The access token is the same regardless of which backend service is called — the identity provider issues a single token, and the BFF injects it into all proxied requests.

YARP supports multiple destinations per cluster for load balancing:

{
"Clusters": {
"backend-api": {
"LoadBalancingPolicy": "RoundRobin",
"Destinations": {
"instance-1": {
"Address": "https://api-1.internal:5001"
},
"instance-2": {
"Address": "https://api-2.internal:5001"
},
"instance-3": {
"Address": "https://api-3.internal:5001"
}
}
}
}
}

Available policies: FirstAlphabetical, RoundRobin, Random, LeastRequests, PowerOfTwoChoices.

By default, YARP forwards the full path to the upstream. Use Transforms to rewrite paths:

{
"Routes": {
"api-v2": {
"ClusterId": "backend-api",
"Match": {
"Path": "/api/v2/{**catch-all}"
},
"Transforms": [
{ "PathRemovePrefix": "/api/v2" },
{ "PathPrefix": "/api" }
],
"Metadata": {
"Granit.Bff.RequireAuth": "true"
}
}
}
}

This transforms /api/v2/products/123 into /api/products/123 before forwarding.

By default, YARP forwards most request headers. The BFF transforms add or override specific headers:

HeaderSet byValue
AuthorizationBffTokenInjectionTransformBearer {accessToken}
X-Bff-Session-RefreshedBffTokenInjectionTransformtrue (only after refresh)
X-Forwarded-ForYARP (automatic)Client IP
X-Forwarded-ProtoYARP (automatic)https
X-Forwarded-HostYARP (automatic)Original Host header

The X-CSRF-Token header is consumed by BffCsrfValidationTransform and not forwarded to the upstream — the backend does not need to see it.

ScenarioHTTP statusSourceSPA action
No session cookie401BffTokenInjectionTransformRedirect to /bff/login
Session expired in Redis401BffTokenInjectionTransformRedirect to /bff/login
Token refresh failed401BffTokenInjectionTransformRedirect to /bff/login
Missing CSRF token (mutating)403BffCsrfValidationTransformFetch new CSRF token
Invalid CSRF token403BffCsrfValidationTransformFetch new CSRF token
Upstream returned errorPass-throughBackend APIHandle in SPA
async function handleApiResponse(response: Response): Promise<Response> {
if (response.status === 401) {
// Session expired or invalid — re-authenticate
window.location.href = "/bff/login";
throw new Error("Session expired");
}
if (response.status === 403) {
// CSRF token expired — fetch a new one and retry
const csrfResponse = await fetch("/bff/csrf-token", {
method: "POST",
credentials: "include",
});
const { csrfToken } = await csrfResponse.json();
// Store new token and retry the original request
// ...
}
return response;
}
AspectImpactMitigation
Redis round-trip per request~0.5ms per GetRedis connection pooling, local cache for hot sessions
Token refresh adds latency~100-500ms (IdP round-trip)Proactive refresh via RefreshGracePeriod (default 1 min before expiry)
CSRF HMAC computation~0.01msCPU-only, no I/O
YARP streamingNegligible overheadZero-copy buffering, kernel-level optimization

The BFF adds minimal latency to proxied requests. The primary overhead is the Redis lookup (~0.5ms per request), which is typically negligible compared to backend processing time.

  • Configuration — full options reference, metrics, and tracing
  • Security — cookie hardening and session management deep dive
  • Architecture — review all flow diagrams