A Backend-for-Frontend library for ASP.NET Core
GitHub Repository | NuGet Package | Documentation
We are happy to announce the release of b17s.Porta:
an easy to use, customizable Backend-for-Frontend library for ASP.NET Core.
Porta is built for teams that want the benefits of a dedicated BFF layer without wiring the same plumbing over and over again. It hooks directly into ASP.NET Core minimal APIs and adds
ProductionPorta is not yet battle-hardened. For production scenarios, we currently recommend running it behind a reverse proxy instead of exposing it directly at the edge, or further harden the ASP.NET Core request pipeline.
The Backend-for-Frontend pattern is simple in theory:
each frontend gets a backend tailored to its exact needs.
If you have ever built a BFF that fans out to multiple internal services, reshapes their responses, hides internal fields, forwards user tokens, calls one backend with Basic Auth, another with Bearer tokens, and then glues the result into a frontend-specific contract:
Porta is the library that turns that work into a few focused endpoint builders.
Porta was designed with ease of use in mind to reduce boilerplate code as much as possible while at the same time allowing consumers to modify each component of this library.
The goal is not to hide ASP.NET Core. The goal is to make BFF code feel native to ASP.NET Core while removing the repetitive infrastructure code around it.
We decided to make use of ASP.Net Core's minimal API pattern, hook into it, and use build-patterns to define endpoints, wiring and their behavior.
var app = builder.Build();
app.MapPassThrough<ProductsResponse>()
.FromGet("/api/products")
.ToGet("https://products-api.internal/products")
.AllowAnonymous()
.Build();
app.Run();
If required, backend responses can be processed and transformed inside Porta's Transformers. Reshape backend responses into frontend contracts in one focused place. Redact internal fields, rename properties, flatten structures, or build entirely new response models. You simply define the DTOs and transformers and add them to the builder:
builder.Services.AddTransformer<ProductsTransformer>();
app.MapTransformer<ProductsTransformer, ProductsResponse>()
.FromGet("/api/products")
.ToGet("https://products-api.internal/products")
.AllowAnonymous()
.Build();
public class ProductsTransformer
: PassThroughTransformer<ProductsResponse>
{
protected override ProductsResponse TransformResponse(
ProductsResponse response,
TransformerContext context)
=> response with
{
Items = response.Items
.Select(i => i with { CostPrice = null })
.ToList()
};
}
Combine data from multiple backend services into a single frontend endpoint. Each backend leg can have its own URL, request configuration, authentication policy, and caching behavior.
.ToBackends(b => b
.ToGet("UserInfo", "https://users.internal/userinfo")
.WithAuth(BackendAuthPolicies.BearerToken)
.ToPost("Orders", "https://orders.internal/orders")
.WithTokenExchange("order-api")
.WithRetries(3))
Forward the user's access token, use HTTP Basic credentials from configuration, exchange tokens for backend-specific access, or plug in a custom IBackendAuthHandler for HMAC, API keys, client credentials, legacy systems, or anything else.
Porta validates endpoint and backend configuration during application startup, so misconfigured endpoints fail before traffic reaches them.
Add login, logout, back-channel logout, and optional REST endpoints for session administration without hand-rolling the full OIDC flow.
Stream binary content, file downloads, images, and non-JSON APIs directly through the BFF without defining response DTOs.
// Zero-code file proxy
app.MapRawForward()
.FromGet("/api/files/{id}")
.ToGet("https://files.internal/files/{id}")
.WithBackendAuth(BackendAuthPolicies.BasicAuth)
.AllowAnonymous()
.Build();
Put clean REST endpoints in front of GraphQL backends while keeping frontend consumers decoupled from backend query details.
Use output caching, distributed caching for HA deployments, and per-backend-leg caching for aggregated endpoints.
Get distributed tracing around transformers, backend calls, and BFF execution flow without manually instrumenting every endpoint.
dotnet add package b17s.Porta
No authentication, no transformer, no custom code - just a mapped pass-through endpoint.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPortaCore();
var app = builder.Build();
app.MapPassThrough<ProductsResponse>()
.FromGet("/api/products")
.ToGet("https://products-api.internal/products")
.AllowAnonymous()
.Build();
app.Run();
This is the simplest thing Porta can do: expose a frontend-facing endpoint and forward it to a backend API.
Secure by defaultPorta endpoints require authorization by default. You only add
.AllowAnonymous()when the endpoint is intentionally public. That means a forgotten authorization annotation fails closed, not open.
Here, the backend returns product data that includes CostPrice. The frontend should never see that field, so the transformer removes it.
public class ProductsTransformer : PassThroughTransformer<ProductsResponse>
{
protected override ProductsResponse TransformResponse(
ProductsResponse response,
TransformerContext context)
=> response with
{
Items = response.Items
.Select(i => i with { CostPrice = null })
.ToList()
};
}
Register the transformer and map the endpoint:
builder.Services.AddTransformer<ProductsTransformer>();
app.MapTransformer<ProductsTransformer, ProductsResponse>()
.FromGet("/api/products")
.ToGet("https://products-api.internal/products")
.AllowAnonymous()
.Build();
Now the same endpoint requires a logged-in user and forwards that user's access token to the backend.
builder.Services.AddPortaCore(options =>
{
options.TrustedHosts = ["https://products-api.internal"];
});
builder.Services.AddPortaAuthentication(builder.Configuration);
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapPassThrough<ProductsResponse>()
.FromGet("/api/products")
.ToGet("https://products-api.internal/products")
.RequireAuth()
.WithBackendAuth(BackendAuthPolicies.BearerToken)
.Build();
The frontend calls the BFF. The BFF validates the user session. The backend receives the forwarded token. The endpoint contract stays clean.
Porta is designed to run as N replicas behind a load balancer without sticky sessions. Any request - including the OIDC callback - can land on any replica, and rolling deploys with mixed old/new replicas are fine. No replica holds session state of its own. Everything that matters lives in shared infrastructure:
IDistributedCache (Redis or Valkey). The auth cookie only carries an opaque ticket id; every replica resolves it against the same store.IRefreshLock and the auto-pick steps aside.Wiring this up is three registrations:
// 1. Shared cache for tickets, sessions, and the refresh lock
builder.Services.AddStackExchangeRedisCache(opts =>
{
opts.Configuration = builder.Configuration["Redis:ConnectionString"];
});
// 2. Shared Data Protection keys, encrypted at rest
using var cert = new X509Certificate2(
builder.Configuration["DataProtection:CertPath"]!,
builder.Configuration["DataProtection:CertPassword"]);
builder.Services.AddPortaDataProtectionWithEntityFrameworkStore(
opts => opts.UseNpgsql(builder.Configuration.GetConnectionString("dataprotection-db")),
dp => dp.ProtectKeysWithCertificate(cert));
builder.Services.AddPortaAuthentication(builder.Configuration);
IMPORTANTConifg
SessionAuthentication:DataProtection:ApplicationNamemust be identical on every replica, so they can all read the same key ring. Porta actively checks these prerequisites at startup. Missing shared cache or key persistence logs a warning; genuinely dangerous configurations - unencrypted Data Protection keys, an in-process refresh lock next to a distributed cache - refuse to start outside Development unless you explicitly acknowledge them in code, with a requiredreasonstring that shows up in code review.
The HA Deployment guide covers the full setup, the failure modes when a piece is missing, and why sticky sessions can still help performance even though correctness never needs them.
Porta's security defaults are already locked down - going to production is mostly about replacing the dev-friendly infrastructure fallbacks (in-memory cache, unpersisted keys) with the real thing. Porta is loud about it: everything on this list is either enforced at startup or flagged with a warning event id. Before going to production you should have:
Authority, ClientId, and ClientSecret are validated at startup; the secret belongs in an environment variable or secret store, not in appsettings.jsonRequireHttpsMetadata stays true and Cookie:SecurePolicy stays Always (both are the default; changing them throws at startup outside Development)Cookie:SameSite set to Lax if your IdP is on a different site than the BFFTrustedHosts listing every backend that receives user tokens - startup throws if a WithUserToken() backend is not on the listAllowedRedirectHosts for login/logout redirects - empty means same-origin only; external hosts are rejected with HTTP 400ApplicationNameSessionKeys:Prefix per BFF when multiple BFFs share one RedisOTEL_EXPORTER_OTLP_ENDPOINT) if you want the built-in traces and metrics exportedThe production configuration reference has the complete table, including which settings are enforced at startup and which are caller responsibilities.
Porta is layered deliberately. You should not have to write custom infrastructure code when all you need is a pass-through endpoint. But when an endpoint needs full control, you can go all-in:
| Base Class | Use Case | Code Required | ||
|---|---|---|---|---|
MapPassThrough<T>() | Zero-code pass-through | Config only | ||
PassThroughTransformer<T> | Simple response transformation | Usually one method | ||
AuthenticatedTransformer<T> | Authenticated backend call without request body | Usually one method | ||
AuthenticatedTransformer<TReq, TRes> | Authenticated call with request body | Request and response handling | ||
AggregatingTransformer<T> | Multi-backend aggregation | Backend leg orchestration | ||
TransformerBase<T> | Full custom control | As much as needed |
Backend authentication follows the same philosophy.
The built-in policies cover the common cases:
| Policy | Purpose | ||
|---|---|---|---|
None | No backend authentication | ||
BasicAuth | Static Basic Auth credentials from configuration | ||
BearerToken | Forward the current user's access token | ||
TokenExchange | Exchange the user token for a backend-specific token |
For everything else, implement IBackendAuthHandler.
The repository includes a self-contained demo that brings up a complete BFF topology using .NET Aspire.
It includes:
A container runtime is the only prerequisite. The identity providers, database, and seeded users provision themselves.
cd demo
aspire run
Alternatively:
dotnet run --project Demo.AppHost
The documentation covers the full public surface of the release candidate:
The core model is now in place:
From here, the focus is on hardening the API surface, improving documentation, expanding examples, and collecting feedback from real projects.
Install it, run the demo, build a BFF with it, and tell us what breaks.
Feedback, issues, and real-world use cases are especially valuable while Porta moves from release candidate to battle-hardened 1.0.