Bff.AspNetCore 1.16.1

dotnet add package Bff.AspNetCore --version 1.16.1
                    
NuGet\Install-Package Bff.AspNetCore -Version 1.16.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Bff.AspNetCore" Version="1.16.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Bff.AspNetCore" Version="1.16.1" />
                    
Directory.Packages.props
<PackageReference Include="Bff.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Bff.AspNetCore --version 1.16.1
                    
#r "nuget: Bff.AspNetCore, 1.16.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Bff.AspNetCore@1.16.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Bff.AspNetCore&version=1.16.1
                    
Install as a Cake Addin
#tool nuget:?package=Bff.AspNetCore&version=1.16.1
                    
Install as a Cake Tool

Bff.AspNetCore

A reusable Backend-For-Frontend engine for ASP.NET Core. It terminates authentication server-side: the browser only ever receives an opaque, httpOnly session cookie, while the Keycloak access / refresh / id tokens live in a Redis-backed token vault. Downstream API calls are reverse-proxied through YARP with a Bearer token attached server-side.

Each per-app BFF (bff-katalogos, bff-erevna, ...) is a ~20-line Program.cs wrapping this package.

Why

A SPA that holds a refresh token in browser storage is one XSS away from a long-lived account takeover. The BFF removes the token from the browser entirely. The native branded login form is preserved — the SPA posts credentials to its own-origin BFF, which does server-to-server ROPC against Keycloak with a confidential client.

What it provides

Component Responsibility
Cookie auth + Redis session store __Host-bff-{app} cookie carries only a session id; tokens live in Redis at bff:{app}:sess:{id}
Redis-backed DataProtection key ring Key ring persisted to Redis at bff:{app}:dataprotection-keys so cookies survive pod restarts and work across replicas; per-app scoped via SetApplicationName("bff-{app}")
Server-side ROPC client grant_type=password against KC using the confidential client
YARP token-forwarding transform /bff/api/* → downstream services with Authorization: Bearer attached
Silent refresh + SETNX lock Stale access tokens refreshed server-side; a per-session Redis lock prevents concurrent-tab races
Auth endpoints /bff/config, /bff/login, /bff/logout, /bff/me, /bff/otp/request, /bff/otp/verify, /bff/pin/login, /bff/register, /bff/forgot-password, /bff/reset-password
Anti-forgery (CSRF) Custom X-BFF-Csrf header + Origin/Referer allow-list on state-changing requests
Hardening No request-body logging, TLS to KC, token/secret redaction in logs

Quick start

A complete per-app BFF Program.cs:

using Bff.AspNetCore.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddBffServices(builder.Configuration);

var app = builder.Build();
app.UseBff();
app.Run();

appsettings.json (secrets come from environment / K8s Secret):

{
  "Bff": {
    "AppName": "katalogos",
    "Keycloak": {
      "Authority": "https://identity.dloizides.com",
      "Realm": "onlinemenu",
      "ClientId": "bff-katalogos-client",
      "ClientSecret": "${BFF_CLIENT_SECRET}"
    },
    "Redis": { "ConnectionString": "redis:6379" },
    "Proxy": {
      "SpaUpstream": "http://katalogos-web",
      "Downstreams": [
        { "Segment": "menus", "Upstream": "http://onlinemenu-api" }
      ],
      "AnonymousPaths": [ "/menus/api/v1/public/*" ]
    },
    "Csrf": {
      "AllowedOrigins": [ "https://katalogos.dloizides.com" ]
    },
    "Methods": {
      "Default": [ "Password" ],
      "RoleOverrides": {
        "door-staff": [ "Pin" ]
      }
    },
    "TenantProxy": {
      "TenantServiceUpstream": "http://tenant-api"
    },
    "Registration": {
      "Enabled": null
    }
  }
}

Registration — is self-serve signup a product you offer?

Bff:Registration:Enabled is a nullable bool gating POST /bff/register and the registrationEnabled capability on GET /bff/config (one resolution, so the endpoint and the advertised capability can never disagree):

Value Behaviour
unset (default) Follow TenantProxy.TenantServiceUpstream — the historical behaviour. Signup is on iff an upstream is configured.
false Off, regardless of the upstream. /bff/register answers 501. OTP login, magic-link, forgot / reset password and email verification are unaffected.
true On; asserts an upstream exists. true with no TenantServiceUpstream throws at startup rather than booting a config that claims a feature it cannot serve.

Set false on a product that is sold rather than signed up for. Do not instead delete the TenantProxy section: that upstream is plumbing shared by OTP, magic-link and password recovery, and removing it silently breaks all of them. An upstream hostname must never imply a product decision.

Demo — publishing a demo credential on the login page

🔴 Bff:Demo:Username + Bff:Demo:Password are served verbatim to anonymous callers in the demo block of GET /bff/config, so a login page can print them and a visitor can sign in immediately:

{ "demo": { "publishedUsername": "demo", "publishedPassword": "TryMe!2026" } }

Both keys are required; set neither (the default) and the block is "demo": null — no banner, nothing published. A half-configured section publishes nothing rather than leaking a lone username. Never point this at a real user's credential: whatever is configured here is readable by anyone who can type a URL. It is for a throwaway account that is expected to be abused, whose blast radius is one disposable seeded demo tenant.

Proxy:AnonymousPaths — letting genuinely public routes through

By default the BFF rejects every /bff/api/* request from a caller with no session, before it leaves the BFF. That is right for a dashboard and wrong for the public surfaces most products also have — a ticket link, a public landing page, a signup form. Those downstream endpoints are AllowAnonymous by design, but the BFF 401s them first.

Proxy:AnonymousPaths is the opt-in allowlist of paths that may be forwarded without a bearer. It defaults to empty — nothing is anonymous until a host says so.

"AnonymousPaths": [
  "/kefi/api/v1/ticket/*",
  "/kefi/api/v1/ticket/*/export",
  "/kefi/api/v1/t/*",
  "/kefi/api/v1/t/*/register"
]

Entries are written relative to ApiPathPrefix, so they include the downstream segment/kefi/api/v1/ticket/*, not /api/v1/ticket/*. A BFF commonly fronts several downstreams with overlapping route shapes; scoping by segment stops one entry opening the same path on all of them.

Matching is whole-path, segment by segment — never a bare prefix:

Pattern Matches Does not match
/kefi/api/v1/t /kefi/api/v1/t /kefi/api/v1/tenants, /kefi/api/v1/t/ubb
/kefi/api/v1/t/* /kefi/api/v1/t/ubb /kefi/api/v1/t/ubb/admin
/kefi/api/v1/t/** /kefi/api/v1/t/ubb/anything/deep /kefi/api/v1/tenants

* matches exactly one segment (a token or slug); **, valid only as the final segment, opts into a whole subtree. Exact-by-default is what lets you allow /ticket/{token} without also allowing a destructive /ticket/{token}/erasure sibling nested one segment deeper. Paths containing ., .. or empty segments never match, and a malformed entry throws at startup rather than silently never matching.

🔴 An allowlisted path only stops the BFF blocking the request. The downstream endpoint is still the thing enforcing its own authorization — so list a path only after confirming that endpoint is genuinely anonymous by design. Never list anything under an admin, organizer, or platform surface.

On both branches the BFF stays the sole authority on the bearer: a client-supplied Authorization header is stripped before the anonymous check (an anonymous route is not a way to smuggle a token downstream), and a caller who does have a session still gets their own bearer attached on an anonymous path rather than being downgraded.

The optional Methods section declares which login methods this BFF offers. Default is the set every role gets; RoleOverrides restricts (or grants) a named role a different set. When Methods is omitted the BFF falls back to Password only. Listing Otp in Default opens the /bff/otp/* endpoints; without it they return 501. Listing Pin in Default opens /bff/pin/login (the event-scoped PIN login); without it it returns 501.

🔴 Adoption precondition for ≥ 1.14.0 — read before you bump

BffAuthorityAgreementGuard throws from StartAsync when the BFF's front-channel and back-channel authorities turn out to be two different identity providers. Throwing from StartAsync aborts host startup, so the pod refuses to start and crash-loops.

Before bumping any BFF to ≥1.14.0, Bff:AuthCode:PublicAuthority must be either empty or name the same provider as Bff:Keycloak:Authority.

This is independent of which login methods are enabled — the guard never reads Methods. The literal count of Methods references in the guard's code path is zero. Disabling Passkey (or running password-only) does not protect a service; only fixing PublicAuthority does. The order is always fix config → then bump, never the reverse.

Fails loudly and fails safely are different properties.

The guard converts a silent defect into a startup failure by design. That is correct for a permanent misconfiguration and fatal if applied to a service whose config has not been checked first.

⚠️ The trap: a password-only service reads as "the safe one" and is not. A BFF with Methods.Default = Password never uses the front channel at runtime, so it looks unaffected — but the guard compares configuration, not traffic. If its PublicAuthority names a different provider than its back channel, it will throw on bump exactly like a Passkey-enabled one. Check the value, not the method list. In Kubernetes, read the live Deployment, not the manifest in git: this defect is normally created by an env overlay that overrides only one of the two.

What "the same provider" means

The two authorities are allowed — and often expected — to differ: a public hostname for the browser, an in-cluster service name for the BFF. What must match is the issuer each one advertises in its .well-known/openid-configuration document, which Keycloak pins per deployment via KC_HOSTNAME. Same provider ⇒ identical issuer; different provider ⇒ different issuer.

What happens when the provider is unreachable (1.16.0+)

Only a proven mismatch is fatal. An identity provider that cannot be reached is an absence of evidence, so:

  • Startup is not aborted and readiness is not failed — the BFF also proxies the SPA, so failing readiness would stop serving the whole site rather than just login.
  • A background re-resolve retries with exponential backoff and jitter.
  • If a later read proves the providers differ, the host stops then — by which point the mismatch is evidence rather than a guess.

GET /bff/config publishes what the BFF actually resolved:

{ "issuer": "https://identity.example.com/realms/app", "issuerStatus": "resolved" }
{ "issuer": null, "issuerStatus": "unresolved-unreachable" }      // retrying
{ "issuer": null, "issuerStatus": "unresolved-not-applicable" }   // no authority configured

issuerStatus exists because a bare null issuer (1.15.0) could not be interpreted from outside the pod — "nothing to check" and "provider never confirmed to exist" rendered identically.

Public API

// Registration — bind + validate the `Bff` config section and register
// the whole engine (Redis store, ROPC client, YARP proxy, CSRF, endpoints).
IServiceCollection AddBffServices(
    this IServiceCollection services,
    IConfiguration configuration,
    Action<BffOptions>? configure = null);

// Pipeline — anti-forgery middleware + auth endpoints + reverse proxy.
WebApplication UseBff(this WebApplication app);

The SPA contract

The SPA must:

  1. Send credentials to the BFF, not KeycloakPOST /bff/login with { username, password }. The response body is { user: { ...claims } }; it never contains a token.
  2. Call downstream APIs through /bff/api/* — the cookie is sent automatically; the BFF attaches the Bearer.
  3. Send X-BFF-Csrf: 1 on every state-changing request (POST/PUT/PATCH/DELETE) to /bff/* and /bff/api/*.
  4. Treat 401 from /bff/api/* or /bff/me as "session ended" — redirect to the login form.

Endpoints

Method + path Purpose
GET /bff/config Anonymous capability descriptor — enabled login methods + registrationEnabled + the opt-in published demo credential + the resolved issuer / issuerStatus; read by the SPA before login
POST /bff/login ROPC login; creates the session, sets the cookie
POST /bff/logout KC end-session, deletes the Redis session, clears the cookie
GET /bff/me Current user's sanitised claims, or 401
POST /bff/otp/request Proxied to TenantService send-OTP; 501 when Otp is not an enabled method
POST /bff/otp/verify Email-OTP login (KC direct-grant); creates the session, sets the cookie; 501 when Otp is disabled
POST /bff/pin/login Event-scoped PIN login (KC direct-grant with pin + eventExternalId); creates the session, sets the cookie; 501 when Pin is disabled
POST /bff/register Proxied to TenantService; 501 when Registration:Enabled is false (or no upstream is configured)
POST /bff/forgot-password Proxied to TenantService
POST /bff/reset-password Proxied to TenantService
/bff/api/{segment}/* Reverse-proxied downstream with Bearer attached
/* Reverse-proxied to the SPA's nginx upstream

Security notes

  • The session cookie is __Host--prefixed: httpOnly, Secure, SameSite=Lax, Path=/. It carries only the opaque session id.
  • Refresh-token rotation at the realm level is deferred (Phase 6). The per-session SETNX refresh lock is the concurrency protection until then.
  • Request bodies are never logged — login bodies contain passwords.
  • Set Keycloak.SkipTlsValidation only for staging's self-signed cert.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.16.1 100 7/22/2026
1.16.0 91 7/22/2026
1.15.0 95 7/22/2026
1.14.0 91 7/22/2026
1.13.1 102 7/20/2026
1.13.0 89 7/20/2026
1.12.0 107 7/19/2026
1.11.1 90 7/19/2026
1.11.0 97 7/19/2026
1.10.0 99 7/17/2026
1.9.1 104 7/14/2026
1.9.0 102 7/14/2026
1.8.0 97 7/13/2026
1.7.0 112 7/9/2026
1.6.0 102 7/9/2026
1.5.0 104 7/6/2026
1.4.0 108 7/4/2026
1.2.5 109 5/23/2026
1.2.4 107 5/23/2026
1.2.3 110 5/23/2026
Loading failed