Backend for Frontend: The Browser Gets a Cookie, Not a Token

By Sergey Nosov

20 October 2026

My OpenID Connect article built a React app that signs in through Keycloak and calls an ASP.NET Core API with tokens it holds itself. It ended on that design’s limit. RFC 10017, the IETF’s best current practice for browser-based apps, does not recommend it “for business applications, sensitive applications, and applications that handle personal data.” Both the RFC and Microsoft recommend a backend for frontend (BFF) instead. This article makes the move.

A BFF is not a new OAuth flow. It is an architecture, and the RFC, published in August 2026, ranks it first of the three it describes for browser apps. The name comes from an older pattern, a backend built for one kind of frontend, which Sam Newman described in November 2015, crediting the name to Phil Calcado.

RFC 10017 gives the pattern a security job. A server-side part of the app becomes the OAuth client: it runs the authorization code flow with PKCE, keeps the tokens, gives the browser a cookie that the page’s scripts cannot read, and forwards the app’s API calls with the access token attached. The RFC describes the endpoints such a component offers but does not define their names or formats.

The app, the API, and the Keycloak realm are the first article’s, changed as little as possible. Every sample was tested on 20 and 21 October 2026 against Keycloak 26.8.1, .NET 10 with ASP.NET Core 10.0.13, YARP 2.3.0, Aspire 13.6.2, React 19.3, and Chrome 155.

The tests turned up three problems in the obvious version of a BFF. The proxy passed the browser’s session cookie on to the API. Twenty parallel calls made thirteen token refreshes. With refresh token rotation on, one late request that still carried the old cookie ended the user’s session. They also showed why a routine change of Keycloak’s signing key broke the next sign-in. Many of the checks now run as end-to-end tests against a real Keycloak, and the last sections separate what the demo does for production from what each deployment must add.

My react-webapi-bff repository holds the demo, a sibling of the first article’s react-webapi-oidc, and its code matches the listings here.

What Moves and What Stays

RFC 10017 gives a BFF three responsibilities. It “interacts with the authorization server as a confidential OAuth client.” It “manages OAuth access and refresh tokens in the context of a cookie-based session, avoiding the direct exposure of any tokens to the browser-based application.” And it “forwards all requests to a resource server, augmenting them with the correct access token before forwarding them to the resource server.”

In the demo, that changes four parts:

Serving the app from the BFF’s own origin makes the app’s calls same-origin. The browser sends the cookie on its own, and as RFC 10017 notes, “legitimate interactions between the frontend and the BFF do not require any preflights.” No CORS policy is needed anywhere.

A Confidential Client with a Key

The pattern’s main benefit, in the RFC’s words, is “the BFF’s ability to act as a confidential client.” So “the BFF MUST act as a confidential client by establishing credentials with the authorization server.” The weather-bff client takes these settings in the Keycloak 26.8.1 admin console:

The audience mapper, the role mapper, and the role scope mapping are the first article’s, on the new client’s dedicated scope, with Full Scope Allowed off. The role mapper now writes the role claim into the access token only, because the BFF needs no roles to show a user’s name.

Pushed authorization requests (PAR), defined in RFC 9126 in September 2021, send the authorization request to Keycloak over a direct, authenticated connection first. The browser then carries the client ID and a one-time request_uri to Keycloak instead of the request, so the request’s other parameters never pass through it. The RFC adds that PAR “allows the authorization server to authenticate the client before any user interaction happens.”

ASP.NET Core has pushed by default since .NET 9 whenever the provider’s discovery document lists a PAR endpoint, as Keycloak’s does. With the client’s Pushed authorization request required switch on, Keycloak answered a plain authorization request for this client with “Pushed Authorization Request is only allowed.” A pushed request without the BFF’s signed JWT, and one with a guessed client secret, got “Authentication failed.”

The key. Keycloak reads a client’s public key either from a key imported into the client or from a JSON Web Key Set (JWKS) that the client publishes at a URL. With the URL, a new key needs no change in Keycloak, so the BFF publishes its key at /bff/jwks, and the client’s Use JWKS URL switch is on.

The URL comes from a placeholder: the realm file sets jwks.url to ${BFF_JWKS_URL}, which the AppHost, shown at the end, fills in. Keycloak’s guide says that “you are able to use placeholders to resolve values from environment variables for any realm configuration.”

Keycloak looks the key up by the key ID in the assertion’s header. My first version imported a bare public key into the client and sent no key ID, and the sign-in failed; Keycloak logged “PublicKey wasn’t found in the storage. Requested kid: 'null'.” The BFF now names its key with the thumbprint that RFC 7638 defines, a hash of the key’s public members, so the same key always gets the same ID.

Refresh token rotation is off in this realm, Keycloak’s default. The first article turned it on because RFC 9700 requires, for public clients, that refresh tokens be sender-constrained or rotated. The rule does not extend to confidential clients: “For confidential clients, [RFC6749] already requires that refresh tokens can only be used by the client for which they were issued.” Keycloak accepts the BFF’s refresh tokens only from a caller that signs as the BFF. The section on refreshing shows what rotation costs a BFF.

The Client Assertion

The assertion, in Bff/ClientAssertion.cs, follows the client credentials article:

using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;

// private_key_jwt: the BFF proves who it is with a short-lived JWT
// signed by its private key. Keycloak fetches the public key from
// the BFF's JWKS endpoint, so a new key needs no change in Keycloak.
public sealed class ClientAssertion
{
    private readonly string clientId;
    private readonly string issuer;
    private readonly SigningCredentials credentials;
    private readonly JsonWebTokenHandler handler = new();

    // keyPem is the private key that signs. During a key change,
    // otherKeyPem is the public key before or after it, which the
    // BFF publishes but never signs with.
    public ClientAssertion(string clientId, string issuer,
        string keyPem, string? otherKeyPem = null)
    {
        this.clientId = clientId;
        this.issuer = issuer;

        var key = ECDsa.Create();
        key.ImportFromPem(keyPem);
        // Fail now, not at the first assertion: ClientKey must be the
        // private key, not a public-only PEM.
        _ = key.ExportParameters(includePrivateParameters: true);
        var jwk = PublicJwk(key);
        credentials = new(new ECDsaSecurityKey(key) { KeyId = jwk.Kid },
            SecurityAlgorithms.EcdsaSha256);

        Jwk[] keys = [jwk];
        if (otherKeyPem is not null)
        {
            using var otherKey = ECDsa.Create();
            otherKey.ImportFromPem(otherKeyPem);
            keys = [jwk, PublicJwk(otherKey)];
        }
        Jwks = new { keys };
    }

    // Published at /bff/jwks.
    public object Jwks { get; }

    private sealed record Jwk(string Kty, string Crv, string X, string Y,
        string Kid, string Use, string Alg);

    // Public members only: the private key never leaves the BFF.
    private static Jwk PublicJwk(ECDsa key)
    {
        if (key.KeySize != 256)
        {
            throw new ArgumentException("Client keys must be P-256.");
        }
        var q = key.ExportParameters(includePrivateParameters: false).Q;
        var x = Base64UrlEncoder.Encode(q.X);
        var y = Base64UrlEncoder.Encode(q.Y);
        // The key ID is the key's RFC 7638 thumbprint.
        var kid = Base64UrlEncoder.Encode(SHA256.HashData(
            Encoding.UTF8.GetBytes(
                $$"""{"crv":"P-256","kty":"EC","x":"{{x}}","y":"{{y}}"}""")));
        return new("EC", "P-256", x, y, kid, "sig", "ES256");
    }

    public void AddTo(OpenIdConnectMessage message)
    {
        message.ClientAssertionType =
            "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
        message.ClientAssertion = handler.CreateToken(
            new SecurityTokenDescriptor
            {
                TokenType = "client-authentication+jwt",
                Issuer = clientId,
                // The issuer is the only audience (RFC 7523 update).
                Audience = issuer,
                Claims = new Dictionary<string, object>
                {
                    ["sub"] = clientId,
                    ["jti"] = Guid.NewGuid().ToString(),
                },
                Expires = DateTime.UtcNow.AddMinutes(1),
                SigningCredentials = credentials,
            });
    }
}

The client ID is both issuer and subject, jti makes each assertion unique, and the assertion expires after a minute. The realm’s issuer URL is the only audience, which the pending update to RFC 7523 requires, and the header type is client-authentication+jwt, which it recommends. Keycloak refuses a second use of an assertion, as the client credentials article found, so AddTo signs a new one for every request.

The constructor also builds the key set for /bff/jwks from the public members of the key. The second key it accepts serves a change of keys, which the section on production readiness describes. It refuses a ClientKey without the private half, so a public key passed by mistake stops the BFF at startup, not at the first sign-in.

ASP.NET Core’s OpenID Connect handler has no setting for signed JWTs; it sends a client secret if one is configured, and nothing otherwise. The BFF adds the assertion in two of the handler’s events, shown in the next listing: one for the pushed request and one for redeeming the code. Refresh requests, which the BFF makes itself, add it too.

The BFF: Session, CSRF Defense, and One Route

The BFF is a minimal ASP.NET Core app on .NET 10, with Microsoft.AspNetCore.Authentication.OpenIdConnect 10.0.13, YARP 2.3.0 for forwarding, and Microsoft.Extensions.Caching.Hybrid 10.11.0. Its Bff/Program.cs sets up the services and the order in which requests pass through them:

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

var builder = WebApplication
    .CreateBuilder(args);

builder.AddServiceDefaults();

const string cookie = CookieAuthenticationDefaults.AuthenticationScheme;
const string oidc = OpenIdConnectDefaults.AuthenticationScheme;

string Setting(string key) => builder.Configuration[key]
    ?? throw new InvalidOperationException($"{key} is not set.");

// The client's private key, in PEM. In production it comes from a
// secret store; in development, the AppHost creates one per run.
// OtherClientKey is set only during a change of keys.
var assertion = new ClientAssertion("weather-bff", Setting("Authority"),
    Setting("ClientKey"), builder.Configuration["OtherClientKey"]);

builder.Services.AddSingleton(assertion);
builder.Services.AddSingleton<AccessTokens>();
builder.Services.AddHybridCache();
builder.Services.AddHttpForwarder();

// The session is only as durable as the keys that encrypt it, so
// every instance needs the same key ring and application name.
var dataProtection = builder.Services.AddDataProtection()
    .SetApplicationName("weather-bff");
if (builder.Configuration["DataProtection:KeysPath"] is { } keysPath)
{
    dataProtection.PersistKeysToFileSystem(new DirectoryInfo(keysPath));
}
else if (!builder.Environment.IsDevelopment())
{
    throw new InvalidOperationException(
        "DataProtection:KeysPath is not set.");
}

// Each session gets its own budget, so a script that has taken
// over one session cannot flood the API through the BFF.
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("session", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            context.User.FindFirst("sid")?.Value ?? "",
            _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 100,
                Window = TimeSpan.FromMinutes(1),
            }));
});

builder.Services
    .AddAuthentication(cookie)
    .AddCookie(cookie, options =>
    {
        // HttpOnly and Path=/ are the defaults.
        options.Cookie.Name = "__Host-Http-bff";
        options.Cookie.SameSite = SameSiteMode.Strict;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        // Keycloak's SSO Session Max: no refresh token lasts longer.
        options.ExpireTimeSpan = TimeSpan.FromHours(10);
        options.SlidingExpiration = false;
        // Answer the app's calls with 401, not a redirect.
        options.Events.OnRedirectToLogin = context =>
        {
            context.Response.StatusCode = 401;
            return Task.CompletedTask;
        };
    })
    .AddOpenIdConnect(oidc, options =>
    {
        options.Authority = Setting("Authority");
        options.ClientId = "weather-bff";
        options.ResponseType = OpenIdConnectResponseType.Code;
        options.SaveTokens = true;
        options.MapInboundClaims = false;
        options.TokenValidationParameters.NameClaimType =
            "preferred_username";
        // Give up on Keycloak after 10 seconds, not the default 60.
        options.BackchannelTimeout = TimeSpan.FromSeconds(10);
        // A failed sign-in returns to the app, which offers it again.
        options.Events.OnRemoteFailure = context =>
        {
            context.Response.Redirect("/?signin-failed");
            context.HandleResponse();
            return Task.CompletedTask;
        };
        // Authenticate with a signed JWT, not a secret.
        options.Events.OnPushAuthorization = context =>
        {
            assertion.AddTo(context.ProtocolMessage);
            context.HandleClientAuthentication();
            return Task.CompletedTask;
        };
        options.Events.OnAuthorizationCodeReceived = context =>
        {
            assertion.AddTo(context.TokenEndpointRequest!);
            return Task.CompletedTask;
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

// In production, the built app comes from wwwroot. It runs no
// inline script, so its pages get a strict policy.
app.UseDefaultFiles();
app.UseStaticFiles(new StaticFileOptions
{
    OnPrepareResponse = file =>
    {
        var headers = file.Context.Response.Headers;
        headers.ContentSecurityPolicy =
            "default-src 'self'; base-uri 'none'; frame-ancestors 'none'";
        headers.XContentTypeOptions = "nosniff";
        // Vite names each asset after a hash of its content, so only
        // the page has to be checked again after a deployment.
        headers.CacheControl =
            file.Context.Request.Path.StartsWithSegments("/assets")
                ? "max-age=31536000, immutable"
                : "no-cache";
    },
});

// CSRF defense: the app's scripts send this header. A page on
// another origin cannot send it without a CORS preflight, and
// the BFF approves no preflight.
app.Use((context, next) =>
{
    var path = context.Request.Path;
    if (!path.StartsWithSegments("/api")
        && !path.StartsWithSegments("/bff/user"))
    {
        return next(context);
    }
    if (context.Request.Headers["X-CSRF"] != "1")
    {
        context.Response.StatusCode = 400;
        return Task.CompletedTask;
    }
    // The answers hold personal data, so nothing may cache them.
    context.Response.OnStarting(() =>
    {
        context.Response.Headers.CacheControl = "no-store";
        return Task.CompletedTask;
    });
    return next(context);
});

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

app.MapDefaultEndpoints();
app.MapBffEndpoints();

// The app's only API route: GET /api/forecast to GET /forecast.
app.MapApiRoute(HttpMethods.Get, "/api/forecast",
    Setting("ApiUrl"), "/forecast");

// In development, the Vite dev server serves the app.
if (app.Configuration["SpaDevServer"] is { } spaDevServer)
{
    app.MapDevServer(spaDevServer);
}

app.Run();

Two handlers. The OpenID Connect handler signs the user in, and the cookie handler keeps the session. The cookie handler is the default, so a request without a session gets whatever its OnRedirectToLogin event gives: 401 here, where the default would redirect. The app’s calls are fetch requests, which cannot show Keycloak’s sign-in page; on a 401, the app shows its Sign in link.

The OpenID Connect handler’s response type defaults to id_token, which asks for the ID token in the browser redirect, so the BFF sets it to code. PKCE and a nonce are on by default. SaveTokens keeps the tokens with the session, and MapInboundClaims = false with NameClaimType makes User.Identity.Name the Keycloak user name, as in the API. Two more settings deal with failures, described below: a 10-second limit on every call to Keycloak, and a way back to the app when a sign-in fails.

The cookie. RFC 10017 lists what the session cookie needs. The Secure and HttpOnly flags are musts. SameSite=Strict, the path /, no Domain attribute, and a name that starts with __Host-Http- are recommended. ASP.NET Core sets HttpOnly and the path by default and adds no Domain, so the code sets the other three.

The prefix comes from the IETF’s draft of a new cookie standard. A cookie with that prefix “will have been set with a Secure attribute, an HttpOnly attribute, a Path attribute with a value of /, and no Domain attribute,” which tells the server that it “was set using a Set-Cookie header.”

Chrome supports the prefix from version 140 and Firefox from version 143. Safari does not yet, but it enforces the older __Host- part. In Chrome 155, a page’s script could create a cookie named __Host-planted but not one named __Host-Http-planted. In a browser that supports the prefix, then, an injected script cannot plant a cookie under the BFF’s name.

The session lasts at most 10 hours, Keycloak’s maximum session length, with no sliding renewal, as the RFC suggests: “configure the lifetime of the cookie-based session managed by the BFF to be equal to the maximum lifetime of the refresh token.”

The session lives in the cookie. ASP.NET Core’s Data Protection encrypts the ticket, which holds the user’s claims and the tokens; the access token and the ID token are about 1.2 KB each. In testing, the cookie came to 6.2 KB, which ASP.NET Core split into three: __Host-Http-bff, with the value chunks-2, and two chunks of about 4 KB and 2.2 KB. The browser sends all three with every request to the BFF, including every script and image it serves.

CSRF. Because the browser now authenticates with a cookie, RFC 10017 says that “the BFF MUST implement a proper CSRF defense.” SameSite=Strict is the first layer: the browser sends the cookie only with requests from the same site. The RFC warns that this “falls short when the BFF is hosted alongside other applications within the same site,” since a page on another subdomain counts as the same site.

So the BFF also requires a custom header, as the RFC recommends: “the BFF SHOULD require that the browser-based application includes a custom request header. Cross-origin requests with a custom request header always require a preflight.” The middleware in the listing answers any call to /api or /bff/user that lacks X-CSRF: 1 with 400, and the BFF, which has no CORS policy, approves no preflight. The same middleware marks the answers to those calls Cache-Control: no-store: they hold personal data, which no browser or proxy cache should keep.

Two attacker pages tested both layers. From a page on another site, http://127.0.0.1:8899, a fetch with the header died at its preflight, which got 400, and a simple request without the header arrived with no cookie. From a page on the same site but another origin, https://localhost:8443, standing in for a sibling subdomain, the simple request did carry the cookie, and only the missing header stopped it.

The BFF’s own endpoints and its forwarder are in Bff/BffEndpoints.cs:

using System.Net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Yarp.ReverseProxy.Transforms;

public static class BffEndpoints
{
    private const string Cookie =
        CookieAuthenticationDefaults.AuthenticationScheme;
    private const string Oidc =
        OpenIdConnectDefaults.AuthenticationScheme;

    public static void MapBffEndpoints(this WebApplication app)
    {
        var tokens = app.Services.GetRequiredService<AccessTokens>();
        var assertion = app.Services.GetRequiredService<ClientAssertion>();

        app.MapGet("/bff/login", () => TypedResults.Challenge(
            new() { RedirectUri = "/" }, [Oidc]));

        // A form cannot send X-CSRF, so check where the post came from.
        app.MapPost("/bff/logout", (HttpRequest request) =>
            request.Headers["Sec-Fetch-Site"] == "same-origin"
                ? Results.SignOut(new() { RedirectUri = "/" }, [Cookie, Oidc])
                : Results.StatusCode(403));

        // A session is active while the BFF can still get a token.
        app.MapGet("/bff/user", async (HttpContext context) =>
            await tokens.GetAsync(context) is null
                ? Results.Unauthorized()
                : Results.Ok(new { Name = context.User.Identity?.Name }))
            .RequireAuthorization()
            .RequireRateLimiting("session");

        // Keycloak reads the BFF's public key here.
        app.MapGet("/bff/jwks", () => Results.Json(assertion.Jwks));
    }

    // Forwards one method and path to one API URL, with the
    // session's access token instead of the browser's cookie.
    public static void MapApiRoute(this WebApplication app,
        string method, string path, string apiUrl, string apiPath)
    {
        var tokens = app.Services.GetRequiredService<AccessTokens>();

        app.MapForwarder(path, apiUrl, transforms =>
        {
            transforms.AddPathSet(apiPath);
            transforms.AddRequestHeaderRemove("Cookie");
            transforms.AddRequestTransform(async context =>
            {
                var token = await tokens.GetAsync(context.HttpContext);
                if (token is null)
                {
                    // A status set here ends the request unforwarded.
                    context.HttpContext.Response.StatusCode = 401;
                    return;
                }
                context.ProxyRequest.Headers.Authorization =
                    new("Bearer", token);
            });
            // If the API refuses the token, the session is over too.
            transforms.AddResponseTransform(async context =>
            {
                if (context.ProxyResponse?.StatusCode
                    == HttpStatusCode.Unauthorized)
                {
                    await context.HttpContext.SignOutAsync(Cookie);
                }
            });
        })
        .RequireAuthorization()
        .RequireRateLimiting("session")
        .WithMetadata(new HttpMethodMetadata([method]));
    }

    // Everything else goes to the Vite dev server, cookie removed.
    public static void MapDevServer(this WebApplication app, string url)
    {
        app.MapForwarder("/{**path}", url, transforms =>
            transforms.AddRequestHeaderRemove("Cookie"));
    }
}

Sign-out has to reach Keycloak, so it is a full-page navigation, and the app uses a form post for it. A form cannot add a header, so the sign-out endpoint checks Sec-Fetch-Site instead. Browsers set this header on every request to an HTTPS address, a page cannot change it, and on a post from the BFF’s own pages its value is same-origin. Chrome has sent it since version 76, Firefox since version 90, and Safari since version 16.4, and a browser too old to send it gets 403.

The cross-site page’s form arrived with cross-site and no cookie and got 403. The same-site page’s form carried the cookie, with same-site, and got 403 too; without the check, it would have signed the user out.

The session endpoints. /bff/user is what the RFC calls the “check session” endpoint, whose answer “should indicate to the browser-based application whether the session is active.” A cookie that decrypts does not show that, because Keycloak may have ended the session since. So the BFF counts a session as active only while it can still get an access token for it, and /bff/user asks for one, refreshing it if it is due.

The BFF asks Keycloak only at a refresh, so a session that Keycloak has ended still looks active until then. Without an active session, /bff/user answers 401; with one, it returns the user’s name and nothing else. The BFF decides what the browser learns about the user, where the first article’s app held the whole ID token. /bff/login starts the sign-in. Sign-in and sign-out both return to /, whatever the request says, so neither can be turned into an open redirect. /bff/jwks publishes the key set for Keycloak and needs no session.

The forwarder. The BFF forwards exactly one route. RFC 10017 says that the BFF “MUST enforce strict outbound request controls by validating destination hosts before forwarding requests.” It adds that “restricting the allowed HTTP methods on a per-endpoint basis can further reduce attack vectors.” MapApiRoute maps one method and path to one path on one API, with YARP’s MapForwarder, so each route the app needs is one line in Program.cs. The only such line sends GET /api/forecast to GET /forecast on the one API the BFF knows, and a POST to the same path never reached the API.

YARP copies the incoming request’s headers to the outgoing one by default, and that includes the cookie. Without the AddRequestHeaderRemove line, the API received the browser’s 6,278-character session cookie, tokens included, next to the access token. The RFC spells out the step: “The BFF removes the cookie from the request, attaches the user’s access token to the request, and forwards it to the actual resource server.” Microsoft’s BFF sample for Blazor, which also forwards with YARP, adds the token but keeps YARP’s default headers.

The request transform answers 401 when the session has no token left, and YARP skips forwarding when a transform sets a status code. When the API itself answers 401, it has refused the session’s token. A response transform then signs the session out, so that the BFF and the app agree that it is over.

In development, every other request goes to the Vite dev server, with the cookie removed as well; in production, the BFF serves the built app from wwwroot. That catch-all route must accept more than GET. Microsoft’s documentation notes that “HTTP/2 WebSockets use CONNECT requests rather than GET,” and a GET-only route answered the Vite client’s WebSocket for hot reload with 405.

Signing In, and the Request That Has No Cookie

Signing in runs the authorization code flow between the BFF and Keycloak:

  1. The app links to /bff/login. The BFF creates a PKCE verifier, a state, and a nonce, pushes the request to Keycloak’s PAR endpoint with a signed JWT, and redirects the browser to Keycloak with the request_uri.
  2. Keycloak signs the user in and returns the code to /signin-oidc in a form post through the browser, the handler’s default response mode.
  3. The BFF redeems the code with the verifier and a new signed JWT, validates the ID token, sets the session cookie, and redirects the browser to /.
  4. The app loads and asks /bff/user who is signed in.

Step 4 matters. The redirect in step 3 ends a chain of requests that began on Keycloak’s page, and when Keycloak runs on another site, SameSite=Strict treats the whole chain as cross-site. The demo hides this: localhost:3443 and localhost:3446 are the same site, because ports do not count. With the BFF pointed at Keycloak through 127.0.0.1 instead, the page load after sign-in arrived with Sec-Fetch-Site: cross-site and without the cookie. The app’s fetch to /bff/user, a same-origin request, carried it.

So the page itself must never depend on the session. RFC 10017’s description of the flow already handles this: when the app loads, “it will check with the BFF for an existing session.” The handler’s correlation and nonce cookies, which the callback in step 2 needs, are set with SameSite=None for the same reason.

A failed sign-in can end at the callback in step 2: the user took too long and the correlation cookie expired, Keycloak returned an error, or someone sent a forged code. Unless the handler’s OnRemoteFailure event takes care of it, the failure becomes a server error. The BFF’s event sends the browser to /?signin-failed, where the app shows “Sign-in failed. Try again.” above its Sign in link. A callback with a forged code and state got that redirect.

Refreshing Tokens Once

Keycloak’s access tokens last 5 minutes, so the BFF must renew them. RFC 10017 describes the refresh as typically inline: the BFF performs it while “handling an API call from the frontend,” between receiving the call and forwarding it. The forwarder’s transform and /bff/user ask this class, in Bff/AccessTokens.cs, for the session’s token:

using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

// Gives the forwarder the session's access token, refreshed
// when it has less than a minute left.
public sealed class AccessTokens(
    IOptionsMonitor<OpenIdConnectOptions> oidcOptions,
    ClientAssertion assertion,
    HybridCache cache,
    TimeProvider time,
    ILogger<AccessTokens> logger)
{
    private const string Cookie =
        CookieAuthenticationDefaults.AuthenticationScheme;

    // One refresh per refresh token: requests that arrive during
    // it wait for it, and for a minute after it, requests that
    // still send the old cookie get the same tokens.
    private static readonly HybridCacheEntryOptions oneMinute = new()
    {
        Expiration = TimeSpan.FromMinutes(1),
        LocalCacheExpiration = TimeSpan.FromMinutes(1),
    };

    public sealed record Tokens(
        string Access, string Refresh, DateTimeOffset ExpiresAt);

    public async Task<string?> GetAsync(HttpContext context)
    {
        var result = await context.AuthenticateAsync(Cookie);
        if (result is not { Succeeded: true, Properties: { } props })
        {
            return null;
        }

        var expiresAt = DateTimeOffset.Parse(
            props.GetTokenValue("expires_at")!,
            CultureInfo.InvariantCulture);
        if (expiresAt - time.GetUtcNow() > TimeSpan.FromMinutes(1))
        {
            return props.GetTokenValue("access_token");
        }

        // The cache key is a hash, not the refresh token itself.
        var refreshToken = props.GetTokenValue("refresh_token")!;
        var key = Convert.ToHexString(
            SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)));
        Tokens? tokens;
        try
        {
            tokens = await cache.GetOrCreateAsync(
                key, ct => RefreshAsync(refreshToken, ct), oneMinute);
        }
        catch (Exception e) when (
            e is HttpRequestException or OperationCanceledException
            && expiresAt > time.GetUtcNow())
        {
            // The refresh failed, but the token is still valid. Log it,
            // or the failure goes unseen until the token expires.
            logger.LogWarning(e, "Refresh failed; the access token is "
                + "used until it expires at {ExpiresAt}.", expiresAt);
            return props.GetTokenValue("access_token");
        }

        if (tokens is null)
        {
            // Keycloak refused the refresh token: the session is over.
            await context.SignOutAsync(Cookie);
            return null;
        }

        // Keep the new tokens in the cookie.
        props.UpdateTokenValue("access_token", tokens.Access);
        props.UpdateTokenValue("refresh_token", tokens.Refresh);
        props.UpdateTokenValue("expires_at", tokens.ExpiresAt
            .ToString("o", CultureInfo.InvariantCulture));
        await context.SignInAsync(Cookie, result.Principal, props);
        return tokens.Access;
    }

    // Null when Keycloak refuses the refresh token.
    private async ValueTask<Tokens?> RefreshAsync(
        string refreshToken, CancellationToken cancellationToken)
    {
        var options = oidcOptions.Get(
            OpenIdConnectDefaults.AuthenticationScheme);
        var config = await options.ConfigurationManager!
            .GetConfigurationAsync(cancellationToken);

        var request = new OpenIdConnectMessage
        {
            GrantType = OpenIdConnectGrantTypes.RefreshToken,
            RefreshToken = refreshToken,
            ClientId = options.ClientId,
        };
        assertion.AddTo(request);

        using var response = await options.Backchannel.PostAsync(
            config.TokenEndpoint,
            new FormUrlEncodedContent(request.Parameters),
            cancellationToken);
        var message = new OpenIdConnectMessage(
            await response.Content.ReadAsStringAsync(cancellationToken));

        if (response.IsSuccessStatusCode)
        {
            var expiresIn = int.Parse(
                message.ExpiresIn, CultureInfo.InvariantCulture);
            // A server that does not rotate may send no new refresh
            // token; the current one then stays in use.
            return new(message.AccessToken,
                message.RefreshToken ?? refreshToken,
                time.GetUtcNow().AddSeconds(expiresIn));
        }
        if (message.Error == "invalid_grant") return null;
        throw new HttpRequestException(
            $"Refresh failed: {(int)response.StatusCode} {message.Error}");
    }
}

When. A token with more than a minute left is used as it is. The margin must be shorter than the token’s life. Microsoft’s BFF sample refreshes when less than 5 minutes are left, which suits Microsoft Entra ID: by default it gives an access token a lifetime of “a random value ranging between 60–90 minutes.” Against Keycloak’s 5-minute tokens, the same margin refreshes on every call. With 60-second test tokens and this BFF’s 60-second margin, ten calls made ten refresh requests.

Once. With the tokens in the cookie, every request carries its own copy of the refresh token. My first version shared a refresh among the requests that arrived while it ran, as the first article’s renew() did. When a refresh came due, twenty parallel calls still made thirteen refresh requests, because calls that arrived after a refresh had finished found none in flight. A request that left the browser before a refresh finished but arrived after it sent the old cookie and refreshed again.

With rotation on, as in the first article’s realm, that was fatal. Of the thirteen refreshes, one succeeded and twelve failed with “Maximum allowed refresh token reuse exceeded.” Keycloak then detached the client from the user’s session, so the newest refresh token failed too, with “Session doesn’t have required client.” A single late request with the old cookie did the same damage.

The class above refreshes through HybridCache, keyed by a hash of the refresh token. Microsoft’s documentation states the guarantee: an instance “ensures that only one concurrent caller for a given key calls the factory method, and all other callers using the same instance wait for the result of that call.” The cache also keeps the result for a minute, so a late request with the old cookie gets the new tokens instead of refreshing again. Twenty parallel calls then made one refresh, and the old cookie made none, with rotation on and with it off.

The guarantee ends at the process boundary: the coordination “doesn’t extend to other HybridCache instances, even if they use the same secondary distributed cache.” With several BFF instances, a late request can reach one that never saw the refresh. Without rotation, that costs a second refresh; with rotation, it ends the session. That is why the demo’s realm leaves rotation off. Keycloak’s Revoke Refresh Token switch applies to every client in a realm, so where a realm must rotate for its public clients, keep each BFF session on one instance.

After. The new tokens go back into the cookie. SignInAsync reissues it, and the response to the call that refreshed carried new values for all three parts; YARP left the headers that the BFF had set in place. When Keycloak refuses a refresh token with invalid_grant, the BFF ends the session, as the RFC suggests: “when the BFF learns that a refresh token for an active session is no longer valid, it also makes sense to invalidate the session.” The app gets 401 and shows its Sign in link.

Other failures throw. With Keycloak’s container paused, a call that needed a refresh waited 60 seconds in my first version, the handler’s default back-channel timeout, and then got 504 Gateway Timeout.

The BFF now gives up on Keycloak after 10 seconds, and while the access token has not expired, it uses that token and logs a warning. With Keycloak paused, a call whose token had about 58 seconds left got the forecast after 10.1 seconds. Once the token had expired, the API route answered 504 and /bff/user answered 500, each after 10.0 seconds. The session survived, and the first call after Keycloak resumed refreshed normally.

How a Session Ends

Idle. The first article’s app renewed its token in the background, so an open tab could keep an idle session alive for up to 10 hours. The BFF renews only when a call needs a token. With the realm’s idle timeout cut to 2 minutes and access tokens to 60 seconds, a session left alone for 150 seconds was over. On the next page load, the refresh behind /bff/user failed with “Token is not active,” the BFF deleted the cookie, and the app showed its Sign in link.

With 5-minute tokens, the same page load still showed the user, and the API still answered, because no refresh was due. The BFF ended the session only at the first refresh, 4 minutes after sign-in. So it can take up to 4 minutes to learn that Keycloak has ended a session.

Sign-out. The Sign out button posts to /bff/logout, which signs out of both handlers. The cookie handler deletes the cookie, and the OpenID Connect handler sends the browser to Keycloak’s end-session endpoint with the registered post-logout URI and the ID token as id_token_hint. Keycloak ends its session and returns the browser to /signout-callback-oidc, which redirects to the app.

The redirect to Keycloak’s end-session endpoint is the one place where a token appears in the browser in readable form: the ID token travels in its URL. The token’s audience is the BFF, so the API would refuse it, but it carries the user’s name and email address.

The BFF keeps the ID token from the sign-in, not the ones that refreshes return. Keycloak accepted that original as the hint after it had expired, as the RP-Initiated Logout specification asks: the provider “SHOULD accept ID Tokens when the RP identified by the ID Token’s aud claim and/or sid claim has a current session or had a recent session at the OP, even when the exp time has passed.”

A copied cookie. Sign-out deletes the browser’s cookie, but the session lives in the cookie, and the BFF keeps no record of it. A copy taken before sign-out still got the forecast until its access token came due for a refresh. Then Keycloak answered “Session not active,” and the copy got 401, from the API route and from /bff/user alike. RFC 10017 accepts this for sessions kept in cookies: “It suffices to revoke the user’s access token and/or refresh token to prevent ongoing access to protected resources, without the need to explicitly invalidate the cookie-based session.”

To end every copy at once, keep the session on the server and only its key in the cookie; ASP.NET Core’s cookie handler takes an ITicketStore for this. Sign-out then deletes the session itself, and so can OpenID Connect back-channel logout. With it, Keycloak tells a client that a session has ended elsewhere, through an endpoint that the BFF would have to add itself. The price is shared state, which in the RFC’s words requires “solutions such as ‘sticky sessions’ or ‘session replication.’” The RFC adds that “using server-side sessions with a BFF is only recommended in small-scale scenarios.”

Data Protection keys encrypt the cookie, so a session lasts only as long as they do. In development they live in the user profile, and a session survived a restart of the BFF. In containers, the keys must be stored outside the container and shared by every instance, or each restart signs everyone out.

Every instance also needs the same application name: by default, Microsoft’s documentation says, Data Protection “isolates apps from one another based on their content root paths, even if they share the same physical key repository.” A clone of the repository in another folder could not read a cookie that the original had issued. So the BFF sets the application name, and outside development it refuses to start without a folder for the keys: “DataProtection:KeysPath is not set.” With the name set and the key folder shared, the clone read the original’s cookie and answered {"name":"jane"}.

When Keycloak’s Signing Key Changes

The BFF checks the signature of each ID token it signs in with, and the API of each access token, against Keycloak’s public keys, which both fetch and cache. Keycloak’s guide recommends rotating keys regularly, starting “by creating new keys with a higher priority than the existing active keys.” After that, “all new tokens and cookies will be signed with the new keys.”

Rotated that way, through the admin API, the new key did not disturb existing sessions: their tokens carried the old key’s signature, and Keycloak kept publishing the old key. The next sign-in failed, though, with “IDX10503: Signature validation failed.” ASP.NET Core’s handlers fetch new keys when a token names a key they do not have, but they fail that request. The sign-in after it worked, but that session’s first API call got 401 from the API for the same reason, and the BFF ended the session.

Fetches for an unknown key happen at most once every 5 minutes, so after a second key change within 5 minutes, sign-ins kept failing. Without an unknown key to prompt them, the handlers fetch the keys again only 12 hours after the last fetch, plus a random delay of up to 36 minutes. The first request after that starts the fetch but still uses the old keys.

Publish a key before it signs anything. Keycloak signs with the active key that has the highest priority and publishes the others, so a key added at a lower priority reaches clients first. Once the BFF and the API had fetched such a key, which a restart forced in testing, raising its priority above the old key’s caused no failures. Without a restart, wait at least 13 hours before raising it. Even then, an instance that has handled no request in that time still has the old keys.

What a BFF Does Not Stop

A BFF puts the tokens out of reach. In the demo, the page’s scripts see an empty document.cookie, empty storage, and a user name. RFC 10017 counts three attacks as mitigated: stealing tokens, once or repeatedly, and obtaining new ones. The last fails because an injected script cannot run a flow for a confidential client it cannot authenticate as.

It does not stop an injected script from using the session. A script on the page can call /api/forecast with the header, as the app does, and the BFF attaches the token. Of this attack, the RFC says: “It is commonly accepted that this scenario cannot be stopped or prevented by application-level security measures.” What limits it is what the first article recommended: keep hostile script out, with a Content Security Policy and careful code.

The RFC adds that the BFF “is uniquely placed to observe all traffic” between the app and the APIs, which makes it the ideal place for rate limits and anomaly detection. The demo’s BFF gives each session 100 calls a minute, counted by the session ID that Keycloak puts in the ID token. A loop of calls in one session got 429 Too Many Requests on its 101st call, while a second session’s call still reached the API.

The BFF serves the app, so it also sends the policy. The built app runs no inline script, and its pages carry default-src 'self'; base-uri 'none'; frame-ancestors 'none', which allows scripts only from the BFF’s own origin and lets no page frame the app. An inline script injected into the served page did not run; Chrome reported that it “violates the following Content Security Policy directive 'default-src 'self''.” A page on another site could not frame the app either. The Vite dev server injects inline script, so development runs without the policy.

Nor does encryption protect a stolen cookie. Malware that copies it can replay it from elsewhere; encrypting the cookie, the RFC says, “still allows the attacker to use the encrypted cookie in a session hijacking attack.”

The App: No Tokens, No OAuth Library

The browser app is now plain React 19.3 on Vite 8. Its calls to the BFF go through one module, spa/src/bff.ts:

// The BFF refuses API calls without this header.
const headers = { "X-CSRF": "1" };

export type User = { name: string };

// null: no session.
export async function getUser(): Promise<User | null> {
  const res = await fetch("/bff/user", { headers });
  if (res.status === 401) return null;
  if (!res.ok) throw new Error(`BFF: ${res.status}`);
  return res.json();
}

export function apiGet(path: string) {
  return fetch(`/api${path}`, { headers });
}

Fetch sends cookies with same-origin requests by default, so nothing here mentions the cookie; the header is the only addition. The component in spa/src/App.tsx decides what to show:

import { useEffect, useState } from "react";
import { apiGet, getUser, type User } from "./bff";

// The BFF returns here with ?signin-failed after a failed sign-in.
const signinFailed = new URLSearchParams(location.search)
  .has("signin-failed");
if (signinFailed) history.replaceState(null, "", location.pathname);

export default function App() {
  // undefined: not known yet.
  const [user, setUser] = useState<User | null>();
  const [message, setMessage] = useState(
    signinFailed ? "Sign-in failed. Try again." : "",
  );
  const [result, setResult] = useState("");

  useEffect(() => {
    getUser().then(setUser, () => {
      setUser(null);
      setMessage("The server did not answer. Try again later.");
    });
  }, []);

  if (user === undefined) return <p>Loading...</p>;
  if (user === null) {
    return (
      <>
        {message && <p>{message}</p>}
        <a href="/bff/login">Sign in</a>
      </>
    );
  }

  async function showForecast() {
    try {
      const res = await apiGet("/forecast");
      if (res.status === 401) {
        // The session has ended.
        setUser(null);
        return;
      }
      setResult(res.ok ? await res.text() : `API: ${res.status}`);
    } catch {
      setResult("The request failed. Try again.");
    }
  }

  return (
    <>
      <p>Signed in as {user.name}</p>
      <button onClick={showForecast}>Show forecast</button>
      <form method="post" action="/bff/logout">
        <button>Sign out</button>
      </form>
      <pre>{result}</pre>
    </>
  );
}

Signing in is a link, because it is a navigation to the BFF, and signing out is a form, for the reason given above. A 401 from the API route means that the session is over, and the app shows the link again. A 403 still means that the user lacks the role: bob signs in and gets “API: 403,” because the check that counts is still the API’s.

When the BFF does not answer, the app says so above the link. After a failed sign-in, it shows its message and removes ?signin-failed from the address, so that a reload does not show it again.

Running It with Aspire

The AppHost adds three things to the first article’s: the BFF, on the port that the redirect URIs name; the BFF’s private key; and the URL from which Keycloak fetches the public key. This is AppHost/AppHost.cs:

using System.Security.Cryptography;

var builder = DistributedApplication
    .CreateBuilder(args);

// The realm's redirect URIs and
// the issuer URL use these ports.
const int keycloakPort = 3443;
const int bffPort = 3446;
const string realm = "demo";
const string keycloakImageTag = "26.8.1";

var authority = $"https://localhost:{keycloakPort}/realms/{realm}";

// A new client key on every run. The BFF gets the private key
// and publishes the public key, which Keycloak fetches.
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var clientKey = builder.AddParameter("bff-client-key",
    key.ExportPkcs8PrivateKeyPem(), secret: true);

var keycloak = builder
    .AddKeycloak("keycloak", keycloakPort)
    .WithImageTag(keycloakImageTag)
    .WithRealmImport("./realms")
    // Keycloak trusts the PEM files in conf/truststores, so it
    // trusts the development certificate that the BFF serves.
    .WithContainerCertificatePaths("/opt/keycloak/conf/truststores");

var api = builder
    .AddProject<Projects.Api>("api")
    .WithEnvironment("Authority", authority)
    .WithHttpHealthCheck("/health")
    .WaitFor(keycloak);

// Reached only through the BFF.
var spa = builder.AddViteApp("spa", "../spa");

var bff = builder.AddProject<Projects.Bff>("bff")
    .WithEndpoint("https", e => e.Port = bffPort)
    .WithEnvironment("Authority", authority)
    .WithEnvironment("ClientKey", clientKey)
    .WithEnvironment("ApiUrl", api.GetEndpoint("https"))
    .WithEnvironment("SpaDevServer", spa.GetEndpoint("http"))
    .WithHttpHealthCheck("/health")
    .WaitFor(api)
    .WaitFor(spa);

// The realm file reads this URL from the environment.
keycloak.WithEnvironment("BFF_JWKS_URL",
    ReferenceExpression.Create($"{bff.GetEndpoint("https")}/bff/jwks"));

builder.Build().Run();

The key is new on every run, and so is the realm: Keycloak runs without a data volume and imports the realm file at startup. The private key reaches the BFF as a secret parameter, which the Aspire dashboard masks, and the BFF publishes the public key.

Keycloak runs in a container, so the AppHost hands it the BFF’s endpoint as a reference, which Aspire resolves to an address that works from inside the container. The BFF’s log showed Keycloak fetching https://aspire.dev.internal:3446/bff/jwks. The development certificate covers that name, and WithContainerCertificatePaths copies it into conf/truststores, a folder whose certificates Keycloak trusts.

The Vite dev server gets a port of Aspire’s choosing and serves only the BFF. As in the first article, a container runtime must be running and the development certificate must be trusted (dotnet dev-certs https --trust). From the repository’s root folder, aspire run or dotnet run --project AppHost then starts Keycloak, the API, the dev server, and the BFF. The app is at https://localhost:3446, with the first article’s two users.

Testing It End to End

The checks in this article began as scripts against a running AppHost. The repository’s Tests project, separate from the app’s projects, turns many of them into end-to-end tests. It starts the whole AppHost with Aspire’s testing builder, Keycloak’s container included. It signs in through Keycloak’s login form as a browser would, with a cookie jar and the forms that Keycloak returns. Through Keycloak’s admin API, it shortens token lifetimes, turns rotation on and off, ends sessions, and counts refresh events.

The thirteen tests cover the role decision, the cookie’s attributes, the session check’s name-only answer, the CSRF header and the refused preflight, and sign-out and its origin check. They also cover the single refresh, with rotation and without, a session ended by an administrator, the rate limit, the published keys and Keycloak’s acceptance of the second one, and a failed sign-in.

The tests use MSTest on Microsoft.Testing.Platform and take about a minute and a half, most of it Keycloak’s startup. To run them, stop any running AppHost, whose ports they use, and run dotnet test --project Tests. A GitHub Actions workflow in the repository runs them on a Linux runner, but only when started by hand.

Two settings made them work. Aspire’s testing builder gives resources random ports by default, which would break the redirect URIs that Keycloak checks, so the tests turn that off with DcpPublisher:RandomizePorts=false. And on the workflow’s Linux runner, dotnet dev-certs https --trust failed until SSL_CERT_DIR was set before it ran: “For OpenSSL trust to take effect, '$HOME/.aspnet/dev-certs/trust' must be listed in the SSL_CERT_DIR environment variable.”

The tests cannot see the browser’s rules. An HTTP client applies no SameSite policy, no cookie prefix rules, and no Content Security Policy, so those checks, described above, were made in Chrome.

Production Readiness

Is this a production BFF, then? Its design is the one RFC 10017 recommends, and its code takes the measures that do not depend on where it runs. But a demo cannot be ready for a deployment it cannot see: where the keys live, what stands in front of the BFF, how many instances run, and how Keycloak runs are each deployment’s own decisions. This section separates the two.

What the BFF Does Itself

What Each Deployment Decides

The client key. Keep the private key in a secret store, such as a key vault, and pass it to the BFF as configuration. To change it while one BFF instance runs, restart the BFF with the new key: Keycloak fetches the key set again when an assertion names a key it has not seen. In testing, the BFF restarted with a new key while Keycloak ran on; the session from before the change refreshed with the new key, and a new sign-in worked.

Keycloak fetches a client’s keys at most once every 10 seconds, its option’s description explains, “to avoid DoS attacks against the external keys endpoint.” With that interval raised to 60 seconds, the first refresh after the change failed: Keycloak logged “PublicKey wasn’t found in the storage” and recorded a REFRESH_TOKEN_ERROR event with client_credentials_setup_required. The BFF carried on with the still-valid access token, and only that event showed the failure. That is why the BFF now logs a warning whenever it falls back on a valid token.

With several instances, Keycloak keeps the key set from whichever instance answered its fetch, so during a rolling deployment, some instances would sign with a key that the set lacks. Change keys in three deployments instead:

  1. Publish the new key: set OtherClientKey to its public half, while ClientKey still signs with the old one.
  2. Sign with it: set ClientKey to the new key and OtherClientKey to the old key’s public half.
  3. Remove OtherClientKey.

Let each step reach every instance before the next one starts. Then every instance’s key set holds every key that any instance signs with. The tests check what this relies on. While the BFF publishes a second key, Keycloak accepts a pushed request signed with it and refuses one signed with a key that the BFF does not publish.

The Data Protection keys. They decrypt every session, and with it every refresh token, so they need the client key’s protection. Point DataProtection:KeysPath at storage that survives restarts and that every instance shares, such as a mounted volume. Or change one line to use another of Data Protection’s key stores, such as Azure Blob Storage or a database.

Encrypt the keys at rest: Microsoft’s documentation warns that “if you change the key persistence location, the system no longer automatically encrypts keys at rest.” The ProtectKeysWith… methods add encryption back, with a certificate or a key vault, for example. Microsoft adds that encryption “doesn’t prevent cyberattackers from creating new keys,” so restrict who can reach them; the storage “should have its access limited to the app itself.”

TLS and the proxy in front. The BFF builds its redirect URIs from each request’s scheme and host. Behind a proxy that ends TLS, requests arrive over HTTP, and the sign-in failed with a server error, because Keycloak refused the pushed request: “Invalid parameter: redirect_uri.” With ASPNETCORE_FORWARDEDHEADERS_ENABLED set to true, the BFF honors the proxy’s X-Forwarded-Proto header, and the sign-in redirected to Keycloak again.

The proxy must also pass the public host name in the Host header, because the switch does not read X-Forwarded-Host. That switch, Microsoft warns, “doesn’t enable features such as the KnownProxies option to restrict which IPs forwarders are accepted from,” so it suits a BFF that only the proxy can reach. If anything else can reach the BFF, configure the forwarded headers middleware with the proxy’s address instead. Behind IIS, the ASP.NET Core Module takes care of this.

The host name. Set AllowedHosts to the BFF’s public host name. The BFF then refuses requests for any other host; one with Host: evil.example got 400 Bad Request.

Health probes. The Aspire service defaults map /health and /alive only in development, with the warning that “adding health checks endpoints to applications in non-development environments has security implications.” A load balancer or an orchestrator needs them, so map them in production too, but only where the probes come from, such as a port that only they can reach. RequireHost alone does not do that: Microsoft warns that it is “subject to potential spoofing by clients.”

The edge. The rate limit counts only signed-in calls. Every request for /bff/login, signed in or not, makes the BFF push an authorization request to Keycloak, signed with its key, so an unlimited stream of them reaches Keycloak through the BFF. Limit those per client address in the proxy or firewall in front. Inside the BFF, a limit per address would need the forwarded headers to see the client’s address at all. To protect the passwords, turn on Keycloak’s brute-force detection; its guide notes that it “is disabled by default.”

The cookie or the server. A session kept in the cookie needs no shared store, but the server cannot end it at once, and its 6.2 KB travel with every request. Proxies limit the size of a header. By default, nginx answers 400 Bad Request when “a request header field” exceeds 8 KB, so a few more claims could break every request. If sign-out must end every copy of a session at once, or the cookie grows, keep sessions on the server with an ITicketStore and back-channel logout. Protect the session store as you protect the key ring.

Several instances. HybridCache shares a refresh only within one instance. Keep rotation off, as the demo does, or keep each session on one instance. A distributed cache behind HybridCache lets a late request on another instance find the new tokens, but two instances can still refresh at the same moment. The cache then holds tokens, so it needs the key ring’s protection too.

More APIs. Each further route is one more MapApiRoute line, to the host it names. But the token that the BFF attaches names one API as its audience. If other APIs need tokens of their own, Keycloak can name several APIs in the token’s audience, but then each can use a token it receives at the others. Or the BFF can exchange the session’s token for one per API with OAuth token exchange, defined in RFC 8693, which Keycloak has supported since version 26.2.

Keycloak itself. The demo runs Keycloak in development mode, on its built-in development database. In production, Keycloak needs its own host name, TLS, a database, and, for availability, more than one node. The realm needs the production redirect URIs and JWKS URL, and its maximum session length must match the cookie’s 10 hours: change one and change the other. Rotate its signing keys as described above: publish each new key before it signs, and keep the old one published until the sessions begun before the change have ended.

Watching it. The service defaults send logs, traces, and metrics over OpenTelemetry once OTEL_EXPORTER_OTLP_ENDPOINT is set. Alert on the BFF’s refresh warnings, on runs of 429 answers and of 401 answers from the API, and on Keycloak’s REFRESH_TOKEN_ERROR and LOGIN_ERROR events. Alert on IDX10503 errors too: they can mean a token signed with a key that the BFF or the API does not have yet. Leave IdentityModel’s ShowPII setting off, its default, so that its messages keep tokens and personal data out of the logs.

Updates. Keep .NET, YARP, Keycloak, and the app’s packages patched. The tests make that cheap: run against a new version, they show whether the behavior in this article still holds.

A Checklist

My Software Development Principles series has entries on the Backend for Frontend pattern, defense in depth, and the fail-secure principle, with the signs that each is applied well or badly. For a BFF in front of a browser app, the article comes down to this:

Further Reading