Bff.AspNetCore
1.14.0
See the version list below for details.
dotnet add package Bff.AspNetCore --version 1.14.0
NuGet\Install-Package Bff.AspNetCore -Version 1.14.0
<PackageReference Include="Bff.AspNetCore" Version="1.14.0" />
<PackageVersion Include="Bff.AspNetCore" Version="1.14.0" />
<PackageReference Include="Bff.AspNetCore" />
paket add Bff.AspNetCore --version 1.14.0
#r "nuget: Bff.AspNetCore, 1.14.0"
#:package Bff.AspNetCore@1.14.0
#addin nuget:?package=Bff.AspNetCore&version=1.14.0
#tool nuget:?package=Bff.AspNetCore&version=1.14.0
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.
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:
- Send credentials to the BFF, not Keycloak —
POST /bff/loginwith{ username, password }. The response body is{ user: { ...claims } }; it never contains a token. - Call downstream APIs through
/bff/api/*— the cookie is sent automatically; the BFF attaches theBearer. - Send
X-BFF-Csrf: 1on every state-changing request (POST/PUT/PATCH/DELETE) to/bff/*and/bff/api/*. - Treat 401 from
/bff/api/*or/bff/meas "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; 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
SETNXrefresh lock is the concurrency protection until then. - Request bodies are never logged — login bodies contain passwords.
- Set
Keycloak.SkipTlsValidationonly for staging's self-signed cert.
License
MIT
| Product | Versions 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. |
-
net10.0
- Fido2 (>= 4.0.1)
- Fido2.Models (>= 4.0.1)
- Konscious.Security.Cryptography.Argon2 (>= 1.3.1)
- Microsoft.AspNetCore.DataProtection.StackExchangeRedis (>= 10.0.8)
- StackExchange.Redis (>= 2.8.16)
- Yarp.ReverseProxy (>= 2.2.0)
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 |