BFF Pattern — Securing Modern SPAs with ASP.NET Core and YARP

Posted on: 4/25/2026 7:18:47 AM

You're building an SPA (Single Page Application) with Vue or React, backed by an ASP.NET Core API, authenticating via OAuth 2.0 / OpenID Connect. Everything works: access token stored in localStorage, API calls with Authorization: Bearer ... header. Then one day, you receive a pentest report: "Token exposed via XSS, sessions cannot be revoked, refresh token lives in JavaScript." This is precisely the problem the BFF Pattern solves.

BFF — Backend-For-Frontend — is not a library or framework. It's an architectural pattern where a server-side component sits between the SPA and Identity Provider (IdP), keeping tokens entirely server-side, communicating with the browser only via HttpOnly cookies. This article dives deep into the reasoning, architecture, practical implementation with ASP.NET Core + YARP, and production lessons learned.

85%of XSS-vulnerable SPAs can have tokens stolen from localStorage
0Tokens exposed to the browser with BFF pattern
~200msAverage overhead per request through BFF proxy
RFC 9449DPoP — complementary standard for enhanced BFF

1. The Root Problem: Tokens in the Browser Are a Major Risk

In the traditional SPA model, the authentication flow typically works like this: SPA redirects to IdP → user logs in → IdP returns authorization code → SPA exchanges code for access token + refresh token → stores in localStorage or sessionStorage → makes API calls with Bearer token.

sequenceDiagram
    participant B as Browser SPA
    participant IdP as Identity Provider
    participant API as Backend API

    B->>IdP: 1. Redirect to login (PKCE)
    IdP-->>B: 2. Authorization Code
    B->>IdP: 3. Exchange code → access_token + refresh_token
    IdP-->>B: 4. Tokens returned to JavaScript
    Note over B: ⚠️ Tokens live in JS memory/localStorage
    B->>API: 5. API call + Bearer token
    API-->>B: 6. Response
Figure 1: Traditional SPA authentication flow — tokens are within JavaScript's reach

The problem lies in step 4: tokens are returned to JavaScript. This creates three major risks:

🔓 XSS Token Theft

A single XSS vulnerability (from a third-party dependency, unsanitized input, or compromised CDN) allows an attacker to read localStorage and exfiltrate tokens. Stolen access token + refresh token = full account access until the token expires or is revoked.

🔄 Session Revocation Is Impossible

JWTs are stateless — once issued, there's no way to "revoke" them server-side unless you build a blacklist (losing the stateless advantage). If a user changes their password or gets locked out, the old token continues working until it expires.

📡 Token Relay Over Network

Every API call sends the access token over the network in the Authorization header. If HTTPS is intercepted (corporate proxy, mTLS misconfiguration), the token is fully exposed. With HttpOnly + Secure cookies, the browser manages them automatically and JavaScript cannot access them.

⚠️ OAuth 2.0 Security Best Current Practice

RFC 6749 (OAuth 2.0) originally allowed the Implicit Flow for SPAs. However, the OAuth 2.0 Security BCP (draft-ietf-oauth-security-topics) and OAuth 2.1 now officially recommend: SPAs SHOULD NOT receive and store tokens directly. Instead, use the BFF pattern or a Token Mediating Backend. This is no longer a "nice-to-have" but an IETF-recognized best practice.

2. BFF Pattern: Architecture Overview

The core idea of BFF is simple: place a server between the SPA and everything else. This server (the BFF) handles the entire OAuth/OIDC flow, stores access and refresh tokens in a server-side session, and communicates with the browser exclusively via HttpOnly cookies.

graph TB
    subgraph Browser
        SPA[Vue / React SPA]
    end

    subgraph BFF Server
        COOKIE[Cookie Auth
HttpOnly + Secure + SameSite] SESSION[Server-side Session
access_token + refresh_token] PROXY[YARP Reverse Proxy] CSRF[Anti-CSRF Middleware] end subgraph External IDP[Identity Provider
Entra ID / Keycloak / Auth0] API1[User API] API2[Order API] API3[Product API] end SPA -- "Cookie (automatic)" --> COOKIE COOKIE --> CSRF CSRF --> SESSION SESSION -- "Attach Bearer token" --> PROXY PROXY --> API1 PROXY --> API2 PROXY --> API3 COOKIE -. "OIDC Flow" .-> IDP style SPA fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style COOKIE fill:#e94560,stroke:#fff,color:#fff style SESSION fill:#e94560,stroke:#fff,color:#fff style PROXY fill:#e94560,stroke:#fff,color:#fff style CSRF fill:#e94560,stroke:#fff,color:#fff style IDP fill:#2c3e50,stroke:#fff,color:#fff style API1 fill:#2c3e50,stroke:#fff,color:#fff style API2 fill:#2c3e50,stroke:#fff,color:#fff style API3 fill:#2c3e50,stroke:#fff,color:#fff
Figure 2: BFF Architecture — tokens never leave the server

Detailed flow:

sequenceDiagram
    participant B as Browser (SPA)
    participant BFF as BFF Server
    participant IdP as Identity Provider
    participant API as Backend API

    B->>BFF: 1. GET /bff/login
    BFF->>IdP: 2. OIDC Authorization Request (PKCE)
    IdP-->>B: 3. Login page
    B->>IdP: 4. User authenticates
    IdP-->>BFF: 5. Authorization Code callback
    BFF->>IdP: 6. Exchange code → tokens (server-to-server)
    IdP-->>BFF: 7. access_token + refresh_token
    Note over BFF: Tokens stored in session, NOT sent to browser
    BFF-->>B: 8. Set-Cookie: HttpOnly, Secure, SameSite=Lax

    B->>BFF: 9. GET /api/products (Cookie auto-attached)
    BFF->>BFF: 10. Validate cookie + CSRF header
    BFF->>BFF: 11. Read access_token from session
    BFF->>API: 12. GET /products + Authorization: Bearer {token}
    API-->>BFF: 13. Response data
    BFF-->>B: 14. Response data (no token exposed)
Figure 3: Detailed flow — from login to API calls through BFF

✅ The Key Insight

The browser never sees the access token or refresh token. The cookie only contains a session identifier (or encrypted session data), not the JWT itself. JavaScript cannot read the cookie (HttpOnly). For each request, the SPA calls the BFF, the browser automatically attaches the cookie — SPA code doesn't need token management, interceptors, or refresh logic.

3. Implementing BFF with ASP.NET Core + YARP

YARP (Yet Another Reverse Proxy) is a reverse proxy library developed by Microsoft, deeply integrated into the ASP.NET Core pipeline. Using YARP as the proxy layer in BFF allows you to forward requests to downstream APIs while injecting tokens from the server-side session.

3.1. Project Structure

MyApp.Bff/                  ← BFF project (ASP.NET Core)
├── Program.cs              ← Auth + YARP + middleware configuration
├── appsettings.json        ← OIDC config + YARP routes
├── Endpoints/
│   ├── BffLoginEndpoints.cs    ← /bff/login, /bff/logout, /bff/user
│   └── BffCsrfMiddleware.cs    ← Validate X-CSRF header
└── Transforms/
    └── TokenTransform.cs       ← Attach Bearer token to requests

MyApp.Api/                  ← Downstream API (validates JWT normally)
MyApp.Web/                  ← Vue SPA (static files / dev server)

3.2. Program.cs — Authentication Configuration

var builder = WebApplication.CreateBuilder(args);

// 1. Authentication: Cookie + OIDC
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = "cookie";
    options.DefaultChallengeScheme = "oidc";
    options.DefaultSignOutScheme = "oidc";
})
.AddCookie("cookie", options =>
{
    options.Cookie.Name = "__Host-bff";
    options.Cookie.SameSite = SameSiteMode.Lax;
    options.Cookie.HttpOnly = true;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.ExpireTimeSpan = TimeSpan.FromHours(8);
    options.SlidingExpiration = true;

    // When cookie expires or is invalid → return 401 instead of redirect
    options.Events.OnRedirectToLogin = ctx =>
    {
        ctx.Response.StatusCode = 401;
        return Task.CompletedTask;
    };
})
.AddOpenIdConnect("oidc", options =>
{
    options.Authority = builder.Configuration["Oidc:Authority"];
    options.ClientId = builder.Configuration["Oidc:ClientId"];
    options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];

    options.ResponseType = "code";        // Authorization Code Flow
    options.ResponseMode = "query";       // SameSite cookie compatible
    options.UsePkce = true;               // PKCE is mandatory
    options.SaveTokens = true;            // Store tokens in session
    options.MapInboundClaims = false;     // Keep original claim names

    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("offline_access");  // For refresh token
    options.Scope.Add("api");             // Downstream API scope

    options.GetClaimsFromUserInfoEndpoint = true;
});

// 2. YARP Reverse Proxy
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddTransforms(ctx =>
    {
        ctx.AddRequestTransform(async transformCtx =>
        {
            var token = await transformCtx.HttpContext
                .GetTokenAsync("access_token");
            if (!string.IsNullOrEmpty(token))
            {
                transformCtx.ProxyRequest.Headers
                    .Authorization = new("Bearer", token);
            }
        });
    });

var app = builder.Build();

// 3. Middleware pipeline
app.UseAuthentication();
app.UseAuthorization();

// 4. BFF endpoints
app.MapGet("/bff/login", () => Results.Challenge(
    new AuthenticationProperties { RedirectUri = "/" }))
    .AllowAnonymous();

app.MapGet("/bff/logout", async (HttpContext ctx) =>
{
    await ctx.SignOutAsync("cookie");
    await ctx.SignOutAsync("oidc");
}).RequireAuthorization();

app.MapGet("/bff/user", (HttpContext ctx) =>
{
    var user = ctx.User;
    if (user.Identity?.IsAuthenticated != true)
        return Results.Json(new { isAuthenticated = false });

    return Results.Json(new
    {
        isAuthenticated = true,
        claims = user.Claims.Select(c => new { c.Type, c.Value })
    });
}).AllowAnonymous();

// 5. YARP proxy — authenticated requests only
app.MapReverseProxy().RequireAuthorization();

// 6. SPA fallback
app.MapFallbackToFile("index.html");

app.Run();

3.3. appsettings.json — YARP Routes

{
  "Oidc": {
    "Authority": "https://login.microsoftonline.com/{tenant-id}/v2.0",
    "ClientId": "your-client-id",
    "ClientSecret": "your-client-secret"
  },
  "ReverseProxy": {
    "Routes": {
      "api-route": {
        "ClusterId": "api-cluster",
        "Match": { "Path": "/api/{**catch-all}" },
        "Transforms": [
          { "PathRemovePrefix": "/api" }
        ]
      }
    },
    "Clusters": {
      "api-cluster": {
        "Destinations": {
          "primary": { "Address": "https://api.myapp.com" }
        }
      }
    }
  }
}

3.4. Anti-CSRF Protection

Cookie-based auth is susceptible to CSRF (Cross-Site Request Forgery). The BFF protects against this by requiring every SPA request to include a custom header — the browser automatically attaches cookies, but JavaScript from another domain cannot set custom headers due to the Same-Origin Policy.

// BffCsrfMiddleware.cs
public class BffCsrfMiddleware
{
    private readonly RequestDelegate _next;

    public BffCsrfMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        // Only check for API proxy routes, skip /bff/* endpoints
        if (context.Request.Path.StartsWithSegments("/api"))
        {
            if (!context.Request.Headers.ContainsKey("X-CSRF"))
            {
                context.Response.StatusCode = 403;
                await context.Response
                    .WriteAsync("Missing X-CSRF header");
                return;
            }
        }
        await _next(context);
    }
}

// Register in Program.cs (before MapReverseProxy):
app.UseMiddleware<BffCsrfMiddleware>();

Why X-CSRF header instead of AntiForgeryToken?

ASP.NET Core's AntiForgeryToken requires the server to render a form with a token — not suitable for SPAs. A custom header X-CSRF: 1 (any value) is simpler and equally effective: the browser will trigger a CORS preflight for cross-origin requests with custom headers, and JavaScript from another domain cannot send requests with custom headers to your domain without CORS permission.

4. Vue SPA Integration

On the Vue side (or any SPA), you don't need any auth libraries. No oidc-client-ts, no msal-browser. Just call APIs through the BFF and let the browser manage cookies automatically.

// composables/useAuth.ts
export function useAuth() {
  const user = ref<UserInfo | null>(null)
  const isLoading = ref(true)

  async function fetchUser() {
    try {
      const res = await fetch('/bff/user', {
        credentials: 'include'   // Send cookies
      })
      user.value = await res.json()
    } finally {
      isLoading.value = false
    }
  }

  function login() {
    window.location.href = '/bff/login'
  }

  function logout() {
    window.location.href = '/bff/logout'
  }

  onMounted(fetchUser)

  return { user, isLoading, login, logout }
}

// composables/useApi.ts
export function useApi() {
  async function apiFetch(path: string, options: RequestInit = {}) {
    const res = await fetch(`/api${path}`, {
      ...options,
      credentials: 'include',
      headers: {
        ...options.headers,
        'X-CSRF': '1',           // Anti-CSRF header
        'Content-Type': 'application/json',
      },
    })

    if (res.status === 401) {
      window.location.href = '/bff/login'
      throw new Error('Unauthorized')
    }
    return res
  }

  return { apiFetch }
}

That's all the SPA code you need. No token management, no complex interceptors, no refresh logic. The browser attaches cookies; the server injects tokens.

5. Token Refresh: Transparent to the SPA

When the access token expires, the BFF automatically uses the refresh token to obtain a new one — completely invisible to the SPA.

sequenceDiagram
    participant B as Browser (SPA)
    participant BFF as BFF Server
    participant IdP as Identity Provider
    participant API as Backend API

    B->>BFF: GET /api/orders (Cookie)
    BFF->>BFF: Read access_token from session
    Note over BFF: Token has expired!
    BFF->>IdP: POST /token (refresh_token)
    IdP-->>BFF: Issue new access_token
    BFF->>BFF: Update session
    BFF->>API: GET /orders + Bearer {new_token}
    API-->>BFF: 200 OK + data
    BFF-->>B: 200 OK + data
    Note over B: SPA has no idea the token was just refreshed
Figure 4: Transparent token refresh — the SPA doesn't need to know the token expired
// Handle token refresh in YARP Transform
ctx.AddRequestTransform(async transformCtx =>
{
    var httpCtx = transformCtx.HttpContext;
    var token = await httpCtx.GetTokenAsync("access_token");
    var expiresAt = await httpCtx.GetTokenAsync("expires_at");

    // Check if token is about to expire (60-second buffer)
    if (DateTimeOffset.TryParse(expiresAt, out var exp)
        && exp <= DateTimeOffset.UtcNow.AddSeconds(60))
    {
        var refreshToken = await httpCtx.GetTokenAsync("refresh_token");
        var newTokens = await RefreshAccessTokenAsync(
            httpCtx, refreshToken);

        if (newTokens != null)
        {
            token = newTokens.AccessToken;
            // Update session with new tokens
            var authResult = await httpCtx
                .AuthenticateAsync("cookie");
            authResult.Properties.UpdateTokenValue(
                "access_token", newTokens.AccessToken);
            authResult.Properties.UpdateTokenValue(
                "refresh_token", newTokens.RefreshToken);
            authResult.Properties.UpdateTokenValue(
                "expires_at", newTokens.ExpiresAt.ToString("o"));
            await httpCtx.SignInAsync("cookie",
                authResult.Principal, authResult.Properties);
        }
    }

    if (!string.IsNullOrEmpty(token))
    {
        transformCtx.ProxyRequest.Headers
            .Authorization = new("Bearer", token);
    }
});

6. Comparison: Traditional SPA vs BFF Pattern

Criteria Traditional SPA (Token in Browser) BFF Pattern (Token on Server)
Token storage localStorage / sessionStorage / memory Server-side session (encrypted cookie)
XSS impact Token stolen → full account takeover Cookie HttpOnly → JS cannot read token
CSRF risk None (Bearer token not auto-attached) Present — requires custom header or SameSite cookie
Session revocation Very difficult (JWT is stateless) Easy (delete server-side session)
Token refresh SPA code must handle (complex interceptors) BFF handles transparently
SPA code complexity High (oidc-client-ts, interceptors, token store) Low (just fetch with credentials: 'include')
Network exposure Token in every request header Cookie session ID (no token content)
Deployment complexity Low (SPA static + API) Medium (additional BFF server)
Latency Direct SPA → API Extra hop through BFF (~50-200ms)
OAuth 2.1 compliance Non-compliant (token in browser) Meets recommended practice

7. Session Management at Production Scale

With BFF, the session becomes a critical component. By default, ASP.NET Core stores session data in cookies (encrypted), but in production you need to consider a distributed session store.

7.1. Session Store Options

Session Store Pros Cons Best For
Cookie (encrypted) Simple, stateless, no extra infra 4KB limit, large cookie per request Few claims, small teams
SQL Server Persistent, audit-friendly Higher latency, requires cleanup job Existing SQL Server infrastructure
Redis Fast, automatic TTL, scales well Additional dependency, cost High-traffic, multi-instance
Data Protection API Built into ASP.NET Core Must share key ring across instances Default for cookie encryption

7.2. Data Protection for Multi-Instance Deployments

// When running BFF on multiple instances (k8s, Azure Container Apps)
// you need to share the Data Protection key ring
builder.Services.AddDataProtection()
    .PersistKeysToAzureBlobStorage(
        connectionString, "data-protection", "keys.xml")
    .ProtectKeysWithAzureKeyVault(
        new Uri("https://mykeyvault.vault.azure.net/keys/dp-key"),
        new DefaultAzureCredential());

// Or with Redis:
builder.Services.AddDataProtection()
    .PersistKeysToStackExchangeRedis(
        ConnectionMultiplexer.Connect("redis:6379"),
        "DataProtection-Keys");

8. Handling Multiple Downstream APIs

In a microservices architecture, the BFF typically proxies requests to multiple APIs. YARP supports this through multiple routes and clusters.

graph LR
    SPA[Vue SPA] --> BFF

    subgraph BFF[BFF Server - YARP]
        R1[/api/users/**]
        R2[/api/orders/**]
        R3[/api/products/**]
        R4[/api/notifications/**]
    end

    R1 --> US[User Service
:5001] R2 --> OS[Order Service
:5002] R3 --> PS[Product Service
:5003] R4 --> NS[Notification Service
:5004] style SPA fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style BFF fill:#e94560,stroke:#fff,color:#fff style R1 fill:#e94560,stroke:#fff,color:#fff style R2 fill:#e94560,stroke:#fff,color:#fff style R3 fill:#e94560,stroke:#fff,color:#fff style R4 fill:#e94560,stroke:#fff,color:#fff style US fill:#2c3e50,stroke:#fff,color:#fff style OS fill:#2c3e50,stroke:#fff,color:#fff style PS fill:#2c3e50,stroke:#fff,color:#fff style NS fill:#2c3e50,stroke:#fff,color:#fff
Figure 5: BFF proxying to multiple downstream services
{
  "ReverseProxy": {
    "Routes": {
      "users": {
        "ClusterId": "user-service",
        "Match": { "Path": "/api/users/{**rest}" },
        "Transforms": [
          { "PathPattern": "/users/{**rest}" }
        ]
      },
      "orders": {
        "ClusterId": "order-service",
        "Match": { "Path": "/api/orders/{**rest}" },
        "Transforms": [
          { "PathPattern": "/orders/{**rest}" }
        ]
      },
      "products": {
        "ClusterId": "product-service",
        "Match": { "Path": "/api/products/{**rest}" },
        "Transforms": [
          { "PathPattern": "/products/{**rest}" }
        ]
      }
    },
    "Clusters": {
      "user-service": {
        "Destinations": {
          "primary": { "Address": "https://users.internal:5001" }
        }
      },
      "order-service": {
        "Destinations": {
          "primary": { "Address": "https://orders.internal:5002" }
        }
      },
      "product-service": {
        "Destinations": {
          "primary": { "Address": "https://products.internal:5003" }
        }
      }
    }
  }
}

9. Advanced Security: DPoP and Sender-Constrained Tokens

The BFF pattern solves the token-in-browser problem, but between BFF and downstream APIs, the access token is still a traditional Bearer token — if intercepted in transit, an attacker can replay it. DPoP (Demonstrating Proof-of-Possession) addresses this.

sequenceDiagram
    participant BFF as BFF Server
    participant IdP as Identity Provider
    participant API as Backend API

    BFF->>BFF: 1. Generate DPoP key pair (asymmetric)
    BFF->>IdP: 2. Token request + DPoP proof JWT
    IdP-->>BFF: 3. DPoP-bound access_token
    Note over BFF: Token only valid when accompanied by DPoP proof
from the same key pair BFF->>API: 4. Request + DPoP token + DPoP proof API->>API: 5. Verify: proof.key == token.cnf.jkt API-->>BFF: 6. 200 OK Note over API: If attacker steals token
but lacks private key → REJECT
Figure 6: DPoP — token is bound to BFF's key pair, cannot be replayed

✅ When Do You Need DPoP?

DPoP adds complexity (key management, proof generation per request). If BFF → API communication is entirely within a private network (Kubernetes internal, VPC), the risk of token interception is very low, and DPoP may not be necessary. DPoP matters more when: BFF → API crosses public internet, or high compliance requirements exist (PCI-DSS, financial services).

10. Deployment Patterns

Deploying BFF and SPA on the same origin (same domain + port) is the simplest and most secure setup — no CORS needed, SameSite cookies work naturally.

# Docker Compose
services:
  bff:
    build: ./MyApp.Bff
    ports:
      - "443:8080"
    environment:
      - ASPNETCORE_URLS=http://+:8080
    volumes:
      - ./MyApp.Web/dist:/app/wwwroot  # SPA static files

# BFF serves both SPA files (MapFallbackToFile)
# and proxies API requests (YARP)
# → Same origin, zero CORS issues

10.2. Cross-Origin (When Separate Deployment Is Required)

// If SPA is on CDN (spa.myapp.com) and BFF at api.myapp.com
// → CORS + credentials required

builder.Services.AddCors(options =>
{
    options.AddPolicy("spa", policy =>
    {
        policy.WithOrigins("https://spa.myapp.com")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();  // Required for cookies
    });
});

// Cookie config must change:
options.Cookie.SameSite = SameSiteMode.None;  // Cross-site
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.Domain = ".myapp.com";  // Shared parent domain

⚠️ Cross-origin BFF reduces security

SameSite=None allows cookies to be sent cross-site, expanding the CSRF attack surface. When possible, keep BFF and SPA on the same origin by deploying SPA static files within the BFF or using a front reverse proxy (Nginx, Cloudflare) to route both to the same domain.

11. Monitoring and Observability

The BFF is the central point handling all traffic from the SPA, making it an ideal location for collecting metrics and traces.

📊 Key Metrics to Monitor

Authentication: Login success/failure rate, token refresh frequency, active session count. Proxy: Request latency (P50/P95/P99), error rate per downstream, CSRF rejection count. Security: 401/403 rate, suspicious pattern detection.

📡 Distributed Tracing

The BFF should propagate trace context (W3C TraceContext) to downstream APIs. Each request from SPA → BFF → API forms a complete trace, enabling latency debugging. ASP.NET Core + YARP automatically propagate when OpenTelemetry is configured.

🔍 Audit Logging

Every request through the BFF has user context (from the cookie session). Log request path + user ID + timestamp for an audit trail. This is a significant advantage over a pure API Gateway (which lacks user context at the request routing level).

12. When NOT to Use BFF

The BFF pattern is not a silver bullet. There are situations where it's not appropriate or unnecessary:

  • Public APIs consumed by third parties: BFF serves a specific SPA, not external consumers. External clients should use OAuth2 client credentials flow directly.
  • Native mobile apps: iOS/Android have secure storage (Keychain, Keystore) for safely storing tokens. BFF can still be used but the overhead may exceed the benefit.
  • Server-rendered apps (MVC, Razor Pages, Blazor Server): These already have server-side sessions natively. Adding a BFF layer is redundant.
  • Low-risk internal tools: Admin dashboards behind a VPN that don't handle sensitive data — tokens in memory may be acceptable.

13. Production BFF Deployment Checklist

Cookie Security
HttpOnly ✅ | Secure ✅ | SameSite=Lax (same-origin) or Strict ✅ | __Host- prefix ✅ | No PII in cookie value
CSRF Protection
Custom header (X-CSRF) for all state-changing requests ✅ | SameSite cookie ✅ | Restrictive CORS ✅
Token Management
Short-lived access token (5-15 minutes) ✅ | Refresh token rotation ✅ | Transparent token refresh ✅ | Handle refresh failure → re-login ✅
Session Store
Distributed session store if multi-instance ✅ | Shared Data Protection key ring ✅ | Session cleanup job ✅ | Absolute session timeout ✅
Deployment
BFF + SPA same origin (preferred) ✅ | Health check endpoint ✅ | HTTPS everywhere ✅ | Rate limiting ✅
Monitoring
Auth metrics (login/refresh/failure) ✅ | Proxy latency ✅ | Distributed tracing ✅ | Audit log ✅

Conclusion

The BFF pattern isn't a new invention — but with the evolving security landscape (OAuth 2.1, increasingly sophisticated XSS attacks, tightening compliance requirements), it has become the recommended architecture for any SPA that takes authentication seriously. YARP on ASP.NET Core makes BFF implementation straightforward: a single project that handles OIDC, proxies APIs, and serves SPA static files.

The biggest benefit isn't just security — it's simplifying SPA code. When token management moves entirely to the server, SPA code becomes cleaner, easier to test, and frontend teams don't need deep OAuth flow expertise. That's a win-win for both security and developer experience.

✅ Summary

BFF Pattern = Server holds tokens, browser only has cookies. YARP + ASP.NET Core = proxy + auth in 1 project. Cookie HttpOnly + SameSite + X-CSRF = defense-in-depth. Transparent token refresh = simple SPA code. OAuth 2.1 recommends this pattern for all SPAs.

References: