BFF Pattern — Securing Modern SPAs with ASP.NET Core and YARP
Posted on: 4/25/2026 7:18:47 AM
Table of contents
- 1. The Root Problem: Tokens in the Browser Are a Major Risk
- 2. BFF Pattern: Architecture Overview
- 3. Implementing BFF with ASP.NET Core + YARP
- 4. Vue SPA Integration
- 5. Token Refresh: Transparent to the SPA
- 6. Comparison: Traditional SPA vs BFF Pattern
- 7. Session Management at Production Scale
- 8. Handling Multiple Downstream APIs
- 9. Advanced Security: DPoP and Sender-Constrained Tokens
- 10. Deployment Patterns
- 11. Monitoring and Observability
- 12. When NOT to Use BFF
- 13. Production BFF Deployment Checklist
- Conclusion
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.
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
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
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)
✅ 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
// 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
{
"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
✅ 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
10.1. Same Origin (Recommended)
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
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:
- OAuth 2.0 for Browser-Based Applications (IETF Draft)
- Duende BFF Documentation
- YARP — Yet Another Reverse Proxy by Microsoft
- BFF in ASP.NET Core — Implementing from scratch (Tore Nestenius)
- RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- BFF Pattern Guide with .NET and Vue (Tural Hasanov)
GraphQL Federation — Building a Unified API Gateway for Microservices
Distributed Locking — Solving Race Conditions in Distributed Systems with Redis and .NET 10
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.