static void

Backend for frontend (BFF)

Published Saturday 21 February 2026

BFF

The Backend for frontend pattern simply means each front-end (eg SPA, mobile app) has a specific back end. Rather than create a large API with complicated security for different clients, you can have a single API for each client (which may sit in front of one or more internal APIs).

The downside if you have many clients is more APIs, duplication, versioning drift (it can enable adaptor pattern to control versioning, but tht complexity will accumulate over time).

The BFF api talks to the client with simple cookie authentication, but the api can talk to one or more other apis with OAuth- the key point being that tokens are never exposed to the front-end.

SPAs with bearer tokens considered harmful

Front-end js libs like msal, oidc-client-ts and angular-auth-oidc-client handle the OIDC/OAuth2 protocol flow, but the settings (authority, clientid, scopes) and the tokens, including refresh tokens, are all in the browser. You can easily inspect them in browser dev tools. Worse, they are hackable.

With BFF your tokens may be stored in cookies, but they should be encrypted and served as HTTP only cookies, unreadable by javascript (you can see them in dev tools, but they are encrypted, so unreadable).

The BFF and front-end should be served from the same origin, so there are no CORS issues.

Authentication cookies are vulnerable to CSRF (cross-site request forgery), but you can largely mitigate this:

BFF flow

The BFF provides one or more authentication endpoints (minimally something like /bff/login) which redirect to the IDP.

.Net example


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = "Cookies";
    options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies", options =>
{
    options.Cookie.HttpOnly = true;
    options.Cookie.SameSite = SameSiteMode.Strict;
})
.AddOpenIdConnect("oidc", options =>
{
    options.Authority = "https://example/idp";
    options.ClientId = "client-bff";
    options.ClientSecret = "secret";
    options.ResponseType = "code";

    options.SaveTokens = true; // keep tokens in auth session
    options.GetClaimsFromUserInfoEndpoint = true;

    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("offline_access"); // refresh tokens
    options.Scope.Add("api"); // your API scope
});

builder.Services.AddAuthorization();

// HTTP client to call downstream API
builder.Services.AddHttpClient("downstream", client =>
{
    client.BaseAddress = new Uri("https://example/api");
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

// Trigger OIDC login
app.MapGet("/bff/login", async (HttpContext ctx) =>
{
    if (!ctx.User.Identity?.IsAuthenticated ?? true)
    {
        await ctx.ChallengeAsync("oidc");
        return;
    }

    return Results.Ok();
});

// Logout (BFF session + IdP)
app.MapPost("/bff/logout", async (HttpContext ctx) =>
{
    await ctx.SignOutAsync("Cookies");
    await ctx.SignOutAsync("oidc");
    return Results.Ok();
});

// Current user info for front-end
app.MapGet("/bff/user", (HttpContext ctx) =>
{
    if (!ctx.User.Identity?.IsAuthenticated ?? true)
        return Results.Unauthorized();

    var claims = ctx.User.Claims.Select(c => new { c.Type, c.Value });
    return Results.Ok(claims);
});

//a downstream endpoint that relays to a private internal api
app.MapGet("/api/todos", async (
    IHttpClientFactory httpClientFactory,
    HttpContext ctx) =>
{
    if (!ctx.User.Identity?.IsAuthenticated ?? true)
        return Results.Unauthorized();

    // Get access token from auth session
    var accessToken = await ctx.GetTokenAsync("access_token");
    if (string.IsNullOrEmpty(accessToken))
        return Results.Unauthorized();

    var client = httpClientFactory.CreateClient("downstream");
    client.DefaultRequestHeaders.Authorization =
        new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);

    var response = await client.GetAsync("/todos");
    var content = await response.Content.ReadAsStringAsync();

    return Results.Content(content, response.Content.Headers.ContentType?.ToString());
});

The angular app doesn't have to do much, because the browser attaches the cookies.


this.http.get('/bff/user').subscribe({
  next: user => this.user = user,
  error: () => window.location.href = '/bff/login'
});

Aside: you will probably prefer to use the nugets Microsoft.Identity.Web with Microsoft.Identity.Web.DownstreamApi (which handles token refresh etc: see docs)


builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(Configuration, "AzureAd")
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddDownstreamApi("MyApi", Configuration.GetSection("MyApiScope"))
    .AddInMemoryTokenCaches();

This gives you a IDownsteamApi with a named target to use in the endpoint


app.MapGet("/api/todos", async (IDownstreamApi api, HttpContext ctx) =>
{
    if (!ctx.User.Identity?.IsAuthenticated ?? true)
        return Results.Unauthorized();

    var result = await api.GetForUserAsync<IEnumerable<TodoItem>>("TodoApi", "/todos");
    return Results.Ok(result);
});

Note these tokens must be stored on the server (InMemoryTokenCache above, or SqlServer or Redis); they are not in the authentication cookie like the primary login

Origin checking middleware (example)

//looks like CORS - that blocks the response, this checks the request
var allowedOrigins = new[]
{
    "https://localhost:4200", // dev
    "https://example.com" // Production
};

app.Use(async (context, next) =>
{
    //Blocks cross-origin POST/PUT/PATCH/DELETE
    //browsers automatically add Origin for cross origin and same-origin non-GET
    var origin = context.Request.Headers["Origin"].ToString();
    var referer = context.Request.Headers["Referer"].ToString();

    bool IsAllowed(string value) =>
        !string.IsNullOrEmpty(value) &&
        allowedOrigins.Any(o => value.StartsWith(o, StringComparison.OrdinalIgnoreCase));

    // Only enforce for unsafe HTTP methods
    if (HttpMethods.IsPost(context.Request.Method) ||
        HttpMethods.IsPut(context.Request.Method) ||
        HttpMethods.IsPatch(context.Request.Method) ||
        HttpMethods.IsDelete(context.Request.Method))
    {
        if (!IsAllowed(origin) && !IsAllowed(referer))
        {
            context.Response.StatusCode = StatusCodes.Status403Forbidden;
            await context.Response.WriteAsync("Forbidden: Invalid origin");
            return;
        }
    }

    await next();
});

.Net example with Duende.Bff

These are open source nuget libs for Bff. Duende.Bff is the basic one, and Duende.Bff.Yarp for routing to remote apis (which includes yarp).

Note this uses an in-memory session store, but an EntityFramework extension is available.

See docs


using Duende.Bff;
using Duende.Bff.Yarp;
using Microsoft.AspNetCore.Authentication;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
builder.Services
    .AddBff() //add Duende.Bff
    .AddRemoteApis() //add support for remote API access (Duende.Bff.Yarp)
    .ConfigureOpenIdConnect(options =>
    {
        options.Authority = "https://identityProvider/";
    
        options.ClientId = "interactive.confidential";
        options.ClientSecret = "secret";
        options.ResponseType = "code";
        options.ResponseMode = "query";
    
        options.Scope.Clear();
        options.Scope.Add("openid");
        options.Scope.Add("profile");
        options.Scope.Add("api");
        options.Scope.Add("offline_access");

        options.MapInboundClaims = false;
        options.ClaimActions.MapAll();
        options.GetClaimsFromUserInfoEndpoint = true;
        options.SaveTokens = true;

        options.TokenValidationParameters.NameClaimType = "name";
        options.TokenValidationParameters.RoleClaimType = "role";
    })
    .ConfigureCookies(options =>
    {
        options.Cookie.SameSite = SameSiteMode.Strict;
    });

var app = builder.Build();

app.UseAuthentication();
app.UseBff(); //add antiforgery protection
app.UseAuthorization();

app.MapBffManagementEndpoints();

app.MapStaticAssets();

//add a local API endpoint
app.MapGet("/api/data", async () =>
{
    return Results.Content(
		await File.ReadAllTextAsync("weather.json"), "application/json");
}).RequireAuthorization()
    .AsBffApiEndpoint(); //adds [BffApiAttribute] =CSRF protection

//add a remote API endpoint (via YARP) that requires an access token to be sent to the remote API
app.MapRemoteBffApiEndpoint("/remoteapi", new Uri("https://example.com/api"))
    .WithAccessToken();

//this api references a BlazorWasm project, which pulls the BlazorWasm app from the wwwroot folder
app.MapFallbackToFile("index.html");

app.Run();

Previously: Verified credentials (Wednesday 18 February 2026)