projects

Introducing b17s.Porta

A Backend-for-Frontend library for ASP.NET Core

2026-07-03 Benjamin Belikov
dotnetportabffaspnetcore

GitHub Repository | NuGet Package | Documentation

Releasing Porta

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

  • declarative endpoint model
  • transformer-based response shaping
  • multi-backend aggregation
  • per-backend authentication
  • OIDC integration
  • raw forwarding
  • backend GraphQL support
  • caching, and
  • OpenTelemetry tracing.
Production

Porta 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.


Why Porta exists

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.


What Porta gives you

Minimal API

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();

Transformer Pattern

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()
        };
}

Multi-Backend Aggregation

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))

Per-Backend Authentication

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.

Startup Validation

Porta validates endpoint and backend configuration during application startup, so misconfigured endpoints fail before traffic reaches them.

OIDC Endpoints

Add login, logout, back-channel logout, and optional REST endpoints for session administration without hand-rolling the full OIDC flow.

Raw Forwarding

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();

GraphQL Support

Put clean REST endpoints in front of GraphQL backends while keeping frontend consumers decoupled from backend query details.

Output and Distributed Caching

Use output caching, distributed caching for HA deployments, and per-backend-leg caching for aggregated endpoints.

OpenTelemetry

Get distributed tracing around transformers, backend calls, and BFF execution flow without manually instrumenting every endpoint.


Quick start

0. Installation

dotnet add package b17s.Porta

1. The smallest possible endpoint

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 default

Porta 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.


2. Reshape a response with a transformer

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();

3. Add authentication

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.


How Porta handles High Availability

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:

  • Sessions and auth tickets live in IDistributedCache (Redis or Valkey). The auth cookie only carries an opaque ticket id; every replica resolves it against the same store.
  • Cookies are encrypted with a shared Data Protection key ring. A correlation cookie minted by replica A during the OIDC redirect can be decrypted by replica B when the callback lands there. That is the whole reason sticky sessions are unnecessary.
  • Token refreshes are coordinated across replicas. When a distributed cache is registered, Porta automatically installs a distributed refresh lock, so two replicas refreshing the same user's token at the same moment don't trip strict one-time-use rotation on hardened IdPs. Need a true mutex? Register your own 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);
IMPORTANT

Conifg SessionAuthentication:DataProtection:ApplicationName must 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 required reason string 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.


What production use requires

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:

  • An IdP wired up: Authority, ClientId, and ClientSecret are validated at startup; the secret belongs in an environment variable or secret store, not in appsettings.json
  • HTTPS everywhere: RequireHttpsMetadata 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 BFF
  • TrustedHosts listing every backend that receives user tokens - startup throws if a WithUserToken() backend is not on the list
  • AllowedRedirectHosts for login/logout redirects - empty means same-origin only; external hosts are rejected with HTTP 400
  • For more than one replica: HA setup - shared Redis/Valkey, persistent and encrypted Data Protection keys, a stable ApplicationName
  • A unique SessionKeys:Prefix per BFF when multiple BFFs share one Redis
  • An OTLP endpoint (OTEL_EXPORTER_OTLP_ENDPOINT) if you want the built-in traces and metrics exported
  • A reverse proxy in front of Porta - as noted above, we don't recommend exposing directly at the edge yet

The production configuration reference has the complete table, including which settings are enforced at startup and which are caller responsibilities.


Pick the right level of control

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 ClassUse CaseCode Required
MapPassThrough<T>()Zero-code pass-throughConfig only
PassThroughTransformer<T>Simple response transformationUsually one method
AuthenticatedTransformer<T>Authenticated backend call without request bodyUsually one method
AuthenticatedTransformer<TReq, TRes>Authenticated call with request bodyRequest and response handling
AggregatingTransformer<T>Multi-backend aggregationBackend leg orchestration
TransformerBase<T>Full custom controlAs much as needed

Backend authentication follows the same philosophy.

The built-in policies cover the common cases:

PolicyPurpose
NoneNo backend authentication
BasicAuthStatic Basic Auth credentials from configuration
BearerTokenForward the current user's access token
TokenExchangeExchange the user token for a backend-specific token

For everything else, implement IBackendAuthHandler.


Try the demo

The repository includes a self-contained demo that brings up a complete BFF topology using .NET Aspire.

It includes:

  • a Porta BFF
  • multiple backend services
  • Keycloak integration
  • Zitadel integration
  • seeded identity provider users
  • database provisioning
  • end-to-end login tests

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

Documentation

The documentation covers the full public surface of the release candidate:

Porta Documentation


What comes next

The core model is now in place:

  • minimal API endpoint builders
  • transformer-based response shaping
  • pass-through endpoints
  • multi-backend aggregation
  • per-backend authentication
  • OIDC integration
  • raw forwarding
  • GraphQL support
  • caching
  • telemetry
  • startup validation

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.