← Default view
◧ bff-vs-spa
0:dev-loop*
1:codegen
2:traps
3:sources
survey · 27 citations · 13 min
2026-07-14
itenium-ui · admin console · what a BFF costs you every day

The codegen never notices.
You notice one extra pane.

A path-preserving BFF is codegen-neutral. The OpenAPI document is unchanged. The generated client is byte-identical. Two config lines move — baseUrl and credentials: 'include' — and that is the entire delta on the codegen axis [15].

The real daily bill is paid in the terminal, not the build: one extra process, one four-route Vite proxy, and the loss of Swagger UI's "Try it out" for cookie-authenticated endpoints [11]. Let the BFF start composing per-screen DTOs instead of proxying and you inherit a second OpenAPI document and a merge problem — that is where it gets genuinely expensive [2].

+1
process to run
4
Vite proxy routes
0
lines of generated code changed
~50
hand-written lines, once
1
debugging tool broken
−1
CORS config, deleted
$

git diff — everything the BFF touches

# the spec and the generated client are not in this list
0:git diff --stat spa..bff5 files
 openapi.json                  |  0            ← the document is untouched. path-preserving proxy. [3]
 src/api/schema.d.ts           |  0            ← generated. byte-identical. codegen never ran differently.
 src/api/client.ts             |  3 ++-          ← two config lines. this is the whole codegen story.
 vite.config.ts                |  8 ++++++++    ← the proxy block. 4 routes.
 src/auth/bff.ts               | 52 +++++++++++ ← new. hand-written, once. /bff/user|login|logout [4]
 src/api/tokenRefresh.ts       | 41 ----------- ← DELETED. no token store, no refresh race.
 src/Api/Program.cs            |  6 ------      ← DELETED. AddCors(...) is not needed any more. [1]
1:git diff src/api/client.ts+2 −1
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -1,9 +1,10 @@
 import createClient from "openapi-fetch";
 import type { paths } from "./schema";   // generated from openapi.json — NOT REGENERATED
 
 export const api = createClient<paths>({
-  baseUrl: "https://localhost:7245",
-  headers: { Authorization: `Bearer ${tokenStore.get()}` },
+  baseUrl: "/",                    // same origin. Vite proxies it.
+  credentials: "include",          // the httpOnly BFF session cookie
+  headers: { "X-CSRF": "1" },      // required on every BFF route [3]
 });

That is it. Every generator already has this hook — it is the same hook the bearer token would have used, and what flows through it gets strictly simpler: a constant instead of a token you must acquire, cache, refresh, and race-condition-proof. And because .NET 10 writes the spec to disk at build time (GetDocument.Insider), the codegen pipeline never touches a live server — so it never traverses the BFF, and never has to authenticate at all [9]. The BFF is invisible to codegen.

$

the shape of the proxy decides everything

# three BFF shapes, in ascending order of pain
exit 0
free
Transparent proxy, path-preserving — /api/* → API /api/*
The document is still the API's own. The generated client is unchanged. Only baseUrl (now relative) and credentials: 'include' move. Configure the route as MapRemoteBffApiEndpoint("/api", new Uri("https://remoteHost/api")) and the paths line up with nothing to do in codegen. A free, deliberate choice at day zero — retrofitting it later is not. [3]
exit 1
warn
Proxy with a prefix change — /api/users → https://api/users
This is Duende BFF's default shape, and the one people trip over: MapRemoteBffApiEndpoint("/api/users", new Uri("https://remoteHost/users")) maps a local path onto a different remote path. The downstream document says /users; the browser must call /api/users. Fixable with the generator's baseUrl prefix — if the prefix is uniform. Duende put it plainly: "The server URLs in these documents represent the direct URL to the API. Clients shouldn't access the API directly but through the BFF." [2]
exit 2
fatal
Composing BFF — aggregation, per-screen DTOs
Two OpenAPI documents, or a merged one you must build. Two generated clients, or a document-merge step in CI. Your options: a runtime OpenApiDocumentCombiner in the BFF that rewrites paths, rewrites servers and strips securitySchemes [2]; or yarp-swagger ⭐ 108 — a load-bearing build-chain dependency at 108 stars, and YARP itself ships no built-in OpenAPI aggregation [23]; or .NET 10 document transformers, which only transform your own app's document — merging is still hand-rolled [10].

Bottom line: a proxying BFF is codegen-neutral. A composing BFF is a codegen project.

$

tmux list-panes — the actual daily cost

# one pane per process you must run. this is the whole ergonomics delta.
pure-spa 3 panes
0bun run dev:5173
  VITE v7.1.2  ready in 412 ms

  ➜  Local:   http://localhost:5173/
  ➜  press h + enter to show help
  [hmr] connected.
1dotnet run --project src/Api:7245
info: Now listening on: https://localhost:7245
info: Application started.
warn: CORS policy 'spa' applied
      → origin http://localhost:5173
      AllowCredentials + AllowHeaders
      ← you own this config forever
2docker compose up keycloak:8080
keycloak-1  | Keycloak 27.0.1 started
keycloak-1  | Listening on: http://0.0.0.0:8080
keycloak-1  | Running in development mode
— no fourth pane —
HMR
Swagger "Try it out"
CORS needed
3 terminals
bff 4 panes
0bun run dev:5173
  VITE v7.1.2  ready in 409 ms

  ➜  Local:   http://localhost:5173/
  proxy → https://localhost:7164 (secure:false)
    /bff  /api
    /signin-oidc  /signout-callback-oidc
  [hmr] connected.  ← Vite is the origin. untouched.
1dotnet run --project src/Api:7245
info: Now listening on: https://localhost:7245
info: Application started.
     no CORS policy configured.
     nothing calls this cross-origin any more.
     ─────────────────────────────────────
     unchanged: AddJwtBearer stays as-is.
▲ THE EXTRA PANE — THIS IS THE COST
2dotnet run --project src/Bff:7164
info: Now listening on: https://localhost:7164
info: BFF management endpoints → /bff
      /bff/user  /bff/login  /bff/logout
info: MapRemoteBffApiEndpoint
      /apihttps://localhost:7245/api
      ↑ path-preserving. codegen safe.
info: x-csrf required on all remote routes
3docker compose up keycloak:8080
keycloak-1  | Keycloak 27.0.1 started
keycloak-1  | Listening on: http://0.0.0.0:8080
keycloak-1  | redirect_uri must be the VITE origin
keycloak-1  |   http://localhost:5173/signin-oidc
HMR
Swagger "Try it out"
CORS needed
4 terminals

The BFF adds exactly one process and one vite.config.ts proxy block. That is the whole recurring ergonomics delta on the run-the-app axis — far smaller than the folklore suggests — and it removes the CORS configuration from the API [1]. The F5 loop itself is unchanged: Vite still serves modules, HMR still fires, dotnet watch still recompiles. Nothing is added to the inner loop, because the BFF sits in the request path, not the build path.

Note the two ✗ marks point in opposite directions. The BFF loses you Swagger "Try it out" and gains you the deletion of CORS. Only one of those is a daily irritation — and it is not the one people warn you about.

$

cat vite.config.ts — the four routes, and why Vite must be the proxy

0:vite.config.ts+8 lines, once
server: {
  proxy: {
    '/bff':                    { target: 'https://localhost:7164', changeOrigin: true, secure: false },
    '/api':                    { target: 'https://localhost:7164', changeOrigin: true, secure: false },
    '/signin-oidc':            { target: 'https://localhost:7164', changeOrigin: true, secure: false },  // ← easy to miss
    '/signout-callback-oidc':  { target: 'https://localhost:7164', changeOrigin: true, secure: false },  // ← easy to miss
  }
}

# secure:false → the self-signed cert is only crossed SERVER-side, never by the browser.
#                so you can skip Vite HTTPS, mkcert, and exporting the .NET dev cert entirely. [8]

The two OIDC callback paths are the non-obvious part. /bff/login is a top-level navigation that 302s to Keycloak; Keycloak redirects back to a redirect_uri registered against the Vite origin (http://localhost:5173/signin-oidc) — so Vite must proxy that path back to the BFF or the login loop dead-ends [8]. And the BFF must be told its public origin is :5173 — set changeOrigin and/or ForwardedHeaders, or the OIDC handler builds redirect URIs against :7164 and the round-trip breaks.

1:the deciding argument — who proxies whomHMR
✓  Vite proxies the BFF          Vite is the origin. The HMR WebSocket is direct.
                                 Zero HMR risk.  ← do this

⚠  BFF proxies Vite (SpaProxy)   The .NET proxy now sits in front of Vite's HMR socket.
                                 It needs WebSocket passthrough, or you get Vite's fallback:
                                 "the client will fall back to connecting the WebSocket
                                  directly to the Vite HMR server bypassing the reverse
                                  proxies" — works, but spews console errors. [7]

Let Vite be the proxy, not the proxied. It keeps HMR native, keeps the cookie first-party, and makes the dev-cert problem disappear.

!

the trap everyone falls into: it's the scheme, not the port

devtools → Application → Cookies 401 Unauthorized
  GET /bff/user
   401 Unauthorized   (silently. no error. no clue why.)

  Cookie  __Host-bff-session  SameSite=Lax   ✗ NOT SENT

  ─────────────────────────────────────────────────────────────

  Vite defaults to   http://localhost:5173
  ASP.NET defaults to https://localhost:7164
                     ──────
  same site?  NO.  The "site" is scheme + eTLD+1. Schemeful same-site. [6]
  the port?   IRRELEVANT.  :5173 vs :5000 would have been same-site, fine. [6]

  So the SameSite=Lax session cookie is never sent — and nothing tells you.

The default dev configuration puts you straight into this trap. The fix is the Vite-proxies-BFF topology above: the browser only ever talks to localhost:5173, so the cookie is first-party and the scheme is moot. Do not reach for SameSite=None to force it — it requires Secure, it runs into third-party cookie blocking in Safari and Firefox, and it re-opens the CSRF surface the BFF exists to close [24].

!

the real cost: your backend debugging tooling breaks

# this is the underrated one, and it is where the BFF actually hurts
Swagger UI → Try it out → Execute 401
  POST /api/users     [Try it out]    401 Unauthorized

  Swagger UI sent:   Authorization: Bearer eyJhbGciOi…
                     ↑ which a secure BFF should REJECT [12]
  Swagger UI did NOT send:
                     ✗ the BFF session cookie
                     ✗ the X-CSRF header

  "Cookie authentication is currently not supported for 'try it out'
   requests due to browser security restrictions."  — swagger.io [11]
1:a backend dev's habits, under a cookie BFF
  Swagger UI / Scalar "Try it out"
      pure SPA    paste a token from Keycloak
      under BFF   broken out of the box

  .http file / REST Client / Postman
      pure SPA    Authorization: Bearer …
      under BFF   works only if you hit the API directly, bypassing the BFF
                   — so you are not testing the real path

  curl against the API port
      pure SPA  
      under BFF   same caveat

Two fixes exist, both real work. (1) Serve Swagger UI from the BFF's origin and inject the CSRF header — Duende's exact recipe is c.UseRequestInterceptor("function(request){ request.headers['X-CSRF'] = '1'; return request; }"), combined with a proxied+transformed spec whose servers point at the BFF. Then the browser's existing BFF session cookie is used automatically and "users authenticate once to the BFF, then Swagger UI can explore and test all proxied APIs". This genuinely works [2]. (2) A Swagger UI plugin that routes calls through the BFF and adds CSRF headers, treating Swagger UI as "just another frontend client" [12].

Either way: the API's Swagger UI stops being usable standalone, and someone has to build and maintain the BFF-hosted variant. Budget half a day, once — plus the ongoing reality that backend devs must remember there are now two Swagger UIs and only one of them is honest about auth. Mitigation: in an internal admin console the API is usually still directly reachable in dev, so a backend dev keeps bearer-token .http files for API-only iteration and only exercises the BFF path when debugging auth. The cost is the cognitive split, not a hard block.

$

bun test codegen/bff-compat.spec.ts

# does any generator actually break under a cookie BFF?
0:where the cookie/CSRF config goes, per generator5 pass · 0 fail
openapi-fetch createClient({ baseUrl, credentials: 'include', headers }) — any valid fetch option is accepted [15] ⭐ 8.2k · 2 lines
Orval output.baseUrl + override.mutator (custom fetch/axios instance) [18] ⭐ 6.2k · mutator
Hey API createClient({ baseUrl, credentials, headers }) on the fetch client [19] ⭐ 5.1k · 2 lines
NSwag useTransformOptionsMethod: true → implement transformOptions(RequestInit) [17]
WithCredentials = true does not emit credentials into the Fetch-template client — open issue [16]
⭐ 7.3k · workaround
Kiota AnonymousAuthenticationProvider [14] + a custom fetch function
Kiota TS has no way to pass fetch options out of the box — you hand-write the wrapper; open issue on kiota-typescript ⭐ 61 [13]
⭐ 3.8k · workaround
 5 pass (3 clean, 2 with a documented workaround)   0 fail

 NO GENERATOR IS BROKEN BY A BFF.

 And one genuine simplification: because the BFF terminates auth, the spec can drop
 `securitySchemes` entirely [2]. The pure-SPA path forces you to KEEP the bearer scheme
 in the document AND build a token-refresh interceptor in the client.
 → Net: the BFF removes more client-side auth code than it adds.
$

aspire run — 4 panes collapse to 1

# does Aspire fix the multi-process problem? largely, yes.
0aspire run 1 terminal
  Starting resources…

   keycloak    Running   http://localhost:8080    container, first-class integration
   api         Running   https://localhost:7245
   bff         Running   https://localhost:7164
   frontend    Running   http://localhost:5173   AddViteApp(…).WithNpmPackageInstallation()

  Dashboard  https://localhost:17090   unified logs · traces · status, all four

  Aspire injects the API's assigned address as `services__apiservice__https__0`,
  which vite.config.js reads to configure server.proxy:
    "This eliminates CORS issues during development by proxying requests
     through the same origin."  — .NET Blog [20]
pure SPA
3 terminals
BFF (Vite proxy)
4 terminals
BFF + Aspire
1 terminal

The cost, honestly stated: Aspire is orchestration you now own. The 2026 community read is that it shines when "you have multiple services that need to communicate, shared infrastructure, a need for consistent observability, or complex local development setup" — but "for single-API projects with just a database, Aspire adds unnecessary overhead", and its local dev experience is "considerably more polished than the deployment experience" [22]. Aspire ⭐ 6.2k shipped 13.2 in March 2026 with 1,100+ closed issues, themed on the CLI and "making local development seamless" [21] [27].

With SPA + API + BFF + Keycloak — four processes, two of them containers — you are squarely in the "Aspire pays for itself" band. And note the asymmetry that actually matters for this decision: Aspire helps the BFF option more than it helps the pure-SPA option, because it collapses precisely the multi-process cost the BFF introduces. Adopting Aspire neutralises the BFF's main ergonomics objection.

$

scout verdict --daily-delta

# everything that changes when you add a BFF
0:delta from adding a BFF
=OpenAPI documentNo change if the proxy is path-preserving [3]
=Generated client codeNo change. baseUrl becomes relative; credentials: 'include' + X-CSRF: 1 added [15]
=Codegen pipelineNo change — .NET 10 build-time generation writes the spec to disk; it never traverses the BFF [9]
Client auth codeSimpler. No token store, no refresh interceptor, no bearer scheme in the spec [2]
CORS config in the APIRemoved [1]
=HMR / F5 loopUnchanged — provided Vite is the proxy, not the proxied [7]
HTTPS dev certsNot needed with the Vite-proxy topology (secure: false) [8]
+Hand-written client code+30–50 lines, once: /bff/user, /bff/login, /bff/logout [4]
+Processes to run+1 (3 → 4), or →1 if you adopt Aspire [20]
+vite.config.ts+1 proxy block, 4 routes [8]
~Keycloak client configredirect_uri must be the Vite origin in dev, not the BFF's [8]
Swagger UI "Try it out"Breaks. ~half a day to rebuild a BFF-hosted, CSRF-intercepting Swagger UI [11] [2]
Composing / aggregating BFFDon't. Two OpenAPI docs, a hand-rolled merger, and a 108-star dependency [23] [2]

Summed up. The BFF's ergonomics bill is roughly: one extra process, one config block, one broken debugging tool, and a one-time ~50-line hand-written auth module. Against that, it deletes your CORS config and your entire client-side token-refresh machinery. For an internal admin console with a modest team, that is close to a wash — provided you make two non-negotiable design choices at day zero.

#

two rules. break either and the "quietly free" BFF becomes the expensive one.

RULE 01 — CODEGEN
The BFF is a path-preserving transparent proxy. Never a composer.
/api/*/api/*. The document stays the API's own; the generated client never changes. The moment it composes, codegen becomes a project — two documents, a hand-rolled merger, a build-chain dependency. This is free at day zero and expensive to retrofit.
RULE 02 — DEV LOOP
Vite proxies the BFF. Not the reverse.
Keeps HMR native (Vite owns its own WebSocket), keeps the session cookie first-party so the schemeful-same-site trap can't fire, and makes the HTTPS dev-cert problem disappear entirely (secure: false). The SpaProxy direction works, but it puts a .NET proxy in front of Vite's HMR socket for no benefit.
$

cd .. && ls

# the other angles in this decision framework
expedition
Ship the SPA, not the BFF — but build it so the BFF is a 2–5 day retrofit, because the IETF names business apps explicitly and the supply-chain argument is the one that survives scrutiny.
survey
httpOnly cookies do not stop XSS — they convert silent token exfiltration into loud, tab-bound session riding. That's a real but bounded win; CSP + Trusted Types is the actual mitigation.
recon
The BCP names "business applications" in its strongest recommendation, so an internal console is in scope — but the ranking is a security/simplicity trade-off, not a normative MUST.
survey
Iframe silent renew is dead cross-site but alive same-site; refresh-token rotation works on both stacks, DPoP only really works on Keycloak, and TanStack Query needs a single-flight mutex either way.
survey
Adding a BFF later is a two-way door — if you build the SPA with one auth module, one fetch wrapper, relative paths and no client-side JWT parsing. The one-way door is the API, not the frontend.
expedition
The realistic .NET 10 choice is YARP + AddOpenIdConnect + the free Apache-2.0 Duende.AccessTokenManagement (~250 LOC you own), or Duende.BFF if you qualify for its free Community Edition; the paid entry point is $5,750/yr.
survey
▸ What a BFF costs you every day: OpenAPI codegen and the local dev loop ← you are here
A path-preserving BFF leaves OpenAPI codegen essentially untouched — the real daily cost is one extra process, a Vite proxy config, and losing Swagger UI "Try it out".
$

ls -la sources/

# 27 citations
[exit 0] read the canonical back to the expedition Atlas survey · 27 citations · 13 min · 2026-07-14