The Backend for Frontend pattern is a fairly well understood way to secure a single page application: the browser never sees an access token, cookies handle the session, and the server does the actual talking to the identity provider. What gets less attention is what happens when that same backend needs to call a further downstream API. If you simply forward the user’s delegated token to that API, you end up coupling two systems that should not know about each other. This article walks through a setup that keeps them apart, using an ASP.NET Core and Angular BFF that authenticates users with OpenID Connect and reaches a downstream API with a completely separate OAuth2 client credentials token.
The stack here is OpenIddict as the OpenID Connect server, YARP as the reverse proxy sitting in front of the downstream API, and a small token cache client that handles the client credentials flow. None of this is exotic, but the way the pieces fit together is worth understanding properly before you copy it into a production system.
Why the user token should never reach the downstream API
In a typical BFF, the Angular or React frontend only ever talks to same-origin ASP.NET Core endpoints, protected by a secure, HTTP-only session cookie. That part is standard and most teams get it right. The mistake shows up one layer deeper, when the BFF needs data from another API and someone decides it is simpler to just pass along the user’s access token that OpenID Connect already handed you.
That approach quietly widens the blast radius of the user’s session. The downstream API now has to understand user claims it may not need, trust a token issuer it was never really designed to validate independently, and effectively becomes reachable using credentials meant for browser sessions. Keeping the two flows separate, delegated OIDC for the user, client credentials for app-to-app, means the downstream API only ever sees a token that represents the calling service, not the person sitting at the browser. It also means you can rotate, scope, or revoke the app-to-app credential without touching a single user session.

The Angular UI is served as part of the ASP.NET Core application in production builds and can only reach the backend through cookies. YARP sits inside the same ASP.NET Core host and forwards the request onward to the downstream API, attaching a client credentials token along the way. From the browser’s point of view, there is exactly one origin and one cookie. The two-hop token exchange happens entirely on the server.
Setting up the OpenID Connect client for the user session
The user-facing side of this is standard ASP.NET Core authentication: cookies for the session, OpenID Connect for the challenge, confidential client with authorization code flow. Nothing here is specific to the downstream API story yet.
var stsServer = configuration["OpenIDConnectSettings:Authority"];
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
configuration.GetSection("OpenIDConnectSettings").Bind(options);
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name"
};
});
SaveTokens stores the OIDC tokens against the authentication cookie so the BFF can look at them later if it needs to, but note that this configuration does not use the delegated access token to call the downstream API. That token stays with the user’s session and is not forwarded anywhere. Most OIDC library wrappers you will find are tuned for a specific identity provider, and once your application needs more than one OIDC server or multiple client registrations against the same server, those wrappers tend to get in the way. Sticking closer to the ASP.NET Core standard authentication handlers, as shown here, keeps the setup portable across identity providers.
Routing through YARP and injecting the client credentials token
YARP handles two jobs at once here: it proxies the validated request from the Angular UI to the downstream API, and it attaches an app-to-app access token before the request leaves the BFF. The token injection happens through YARP’s ITransformProvider interface, which lets you modify outgoing requests per route.
using System.Net.Http.Headers;
using Yarp.ReverseProxy.Transforms;
using Yarp.ReverseProxy.Transforms.Builder;
namespace BffOpenIddict.Server.ApiClient;
public class JwtTransformProvider : ITransformProvider
{
private readonly ApiTokenCacheClient _apiTokenClient;
public JwtTransformProvider(ApiTokenCacheClient apiTokenClient)
{
_apiTokenClient = apiTokenClient;
}
public void Apply(TransformBuilderContext context)
{
if (context.Route.RouteId == "downstreamapiroute")
{
context.AddRequestTransform(async transformContext =>
{
var access_token = await _apiTokenClient.GetApiToken(
"CC",
"dataEventRecords",
"cc_secret");
transformContext.ProxyRequest.Headers.Authorization
= new AuthenticationHeaderValue("Bearer", access_token);
});
}
}
public void ValidateCluster(TransformClusterValidationContext context)
{
}
public void ValidateRoute(TransformRouteValidationContext context)
{
}
}
The RouteId check matters more than it looks. If your YARP configuration proxies several routes to different backends, this transform only fires for the route named downstreamapiroute, so other proxied routes are unaffected. Hardcoding the secret name like cc_secret directly in code, as shown in the original sample, is fine for a demo but should move to configuration or a secret store such as Azure Key Vault in anything beyond a local setup.
Wiring the transform provider into the reverse proxy pipeline is a couple of lines during startup.
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms<JwtTransformProvider>();
And the middleware itself, which sits in the usual request pipeline position.
app.MapReverseProxy();
A common mistake here is treating YARP as just a network proxy and forgetting that it also needs its own configuration for development versus deployed environments. Since this setup layers a client credentials call on top of the normal YARP routing, you end up maintaining two YARP configurations, one for local development against a dev STS and downstream API, and one for each deployed environment, test, integration, and production.
Acquiring and caching the client credentials token
The client credentials flow itself has nothing to do with the user. It authenticates the BFF as a service, using its own client ID and secret against the STS token endpoint, and gets back a token scoped to the downstream API. Because this token is not tied to a user session, it can be cached and reused across many requests, which is the whole point of the ApiTokenCacheClient class.
using IdentityModel.Client;
using Microsoft.Extensions.Caching.Distributed;
namespace BffOpenIddict.Server.ApiClient;
public class ApiTokenCacheClient
{
private readonly ILogger<ApiTokenCacheClient> _logger;
private readonly HttpClient _httpClient;
private static readonly object _lock = new();
private readonly IDistributedCache _cache;
private readonly IConfiguration _configuration;
private const int cacheExpirationInDays = 1;
private class AccessTokenItem
{
public string AccessToken { get; set; } = string.Empty;
public DateTime ExpiresIn { get; set; }
}
public ApiTokenCacheClient(
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory,
IConfiguration configuration,
IDistributedCache cache)
{
_httpClient = httpClientFactory.CreateClient();
_logger = loggerFactory.CreateLogger<ApiTokenCacheClient>();
_cache = cache;
_configuration = configuration;
}
public async Task<string> GetApiToken(string api_name, string api_scope, string secret)
{
var accessToken = GetFromCache(api_name);
if (accessToken != null)
{
if (accessToken.ExpiresIn > DateTime.UtcNow)
{
return accessToken.AccessToken;
}
else
{
// remove => NOT Needed for this cache type
}
}
_logger.LogDebug("GetApiToken new from STS for {api_name}", api_name);
// add
var newAccessToken = await GetApiTokenInternal(api_name, api_scope, secret);
AddToCache(api_name, newAccessToken);
return newAccessToken.AccessToken;
}
private async Task<AccessTokenItem> GetApiTokenInternal(string api_name, string api_scope, string secret)
{
try
{
var disco = await HttpClientDiscoveryExtensions.GetDiscoveryDocumentAsync(
_httpClient,
_configuration["OpenIDConnectSettings:Authority"]);
if (disco.IsError)
{
_logger.LogError("disco error Status code: {discoIsError}, Error: {discoError}", disco.IsError, disco.IsError);
throw new ApplicationException($"Status code: {disco.IsError}, Error: {disco.Error}");
}
var tokenResponse = await HttpClientTokenRequestExtensions.RequestClientCredentialsTokenAsync(_httpClient, new ClientCredentialsTokenRequest
{
Scope = api_scope,
ClientSecret = secret,
Address = disco.TokenEndpoint,
ClientId = api_name
});
if (tokenResponse.IsError || tokenResponse.AccessToken == null)
{
_logger.LogError("tokenResponse.IsError Status code: {tokenResponseIsError}, Error: {tokenResponseError}", tokenResponse.IsError, tokenResponse.Error);
throw new ApplicationException($"Status code: {tokenResponse.IsError}, Error: {tokenResponse.Error}");
}
return new AccessTokenItem
{
ExpiresIn = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn),
AccessToken = tokenResponse.AccessToken
};
}
catch (Exception e)
{
_logger.LogError("Exception {e}", e);
throw new ApplicationException($"Exception {e}");
}
}
private void AddToCache(string key, AccessTokenItem accessTokenItem)
{
var options = new DistributedCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromDays(cacheExpirationInDays));
lock (_lock)
{
_cache.SetString(key, System.Text.Json.JsonSerializer.Serialize(accessTokenItem), options);
}
}
private AccessTokenItem? GetFromCache(string key)
{
var item = _cache.GetString(key);
if (item != null)
{
return System.Text.Json.JsonSerializer.Deserialize<AccessTokenItem>(item);
}
return null;
}
}
GetApiToken checks the cache first, returns the cached token if it has not expired, and only calls the STS discovery document and token endpoint when a fresh token is actually needed. That is the correct instinct, most token endpoints are rate limited, and hammering them on every downstream API call adds latency for no benefit. A couple of things are worth flagging if you plan to run this in production rather than a demo, though.
First, IDistributedCache defaults to an in-memory implementation unless you explicitly wire up Redis or SQL Server as the backing store. In a single-instance deployment that is harmless, but the moment you scale this BFF horizontally, each instance ends up with its own in-memory cache and its own copy of the token, which mostly defeats the purpose of caching across the fleet. Point IDistributedCache at Redis in any environment with more than one instance, so all instances share the same cached token and the STS sees a fraction of the traffic it would otherwise get.
Second, the lock statement around AddToCache only protects against concurrent writes within a single process. It does nothing across instances, so if two instances start up at the same moment and their caches are both empty, both will independently call the token endpoint, which is a minor inefficiency rather than a correctness bug. If you want to go further than this sample, the Duende.AccessTokenManagement library handles this exact scenario, including distributed locking around token acquisition and automatic refresh slightly before expiry, and is worth evaluating once this pattern needs to hold up under real production load rather than a single demo instance.
Validating the token on the downstream API
The downstream API has no idea a user is involved anywhere in this chain. It only validates that the incoming bearer token was issued by the expected authority and carries the expected audience.
services.AddAuthentication()
.AddJwtBearer("Bearer", options =>
{
options.Audience = "rs_dataEventRecordsApi";
options.Authority = "https://localhost:44318/";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidAudiences = ["rs_dataEventRecordsApi"],
ValidIssuers = ["https://localhost:44318/"],
};
});
This is a plain JWT bearer setup, but it is worth stressing what it should also do beyond this minimal sample: check the scope claim on the token, not just the issuer and audience. Audience and issuer only prove the token was meant for this API and came from the right STS. Scope validation is what actually enforces that the specific client calling in, identified as CC in the earlier client credentials request, is permitted to do what it is asking for. Skipping scope checks is a common shortcut in demo code that quietly becomes a real gap once more than one client is registered against the same API.
When this pattern earns its complexity, and when it does not
This setup works for any server-rendered application, not just the Angular combination shown here. Swap Angular for React, Vue, Svelte, or Blazor WebAssembly, and the authentication story does not change, only the details of how scripts load and how strict the session security needs to be for that particular frontend framework.
The bigger question is whether you need a separate downstream API at all. Splitting a system into a BFF plus a downstream API adds a network hop, a second set of credentials to rotate, and a second deployment to monitor, all before it delivers any business value. If the BFF and the API being called are always deployed and scaled together, a modular monolith usually performs better and is genuinely simpler to reason about, since there is no token exchange to get wrong in the first place. Reach for this pattern when the downstream API is a genuinely independent service, owned by a different team, scaled differently, or reused by other callers beyond this one BFF, not by default.
One more point worth repeating for teams building this from scratch: never roll your own identity provider for a setup like this. OpenIddict, as used here, or a hosted STS from a vendor, handles the token issuance, signing key rotation, and discovery document correctly out of the box. Building that yourself is expensive to maintain, and a growing number of enterprise environments will not accept a homegrown identity system for compliance reasons regardless of how well it is built.
Leave a Reply