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].
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]
--- 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.
Bottom line: a proxying BFF is codegen-neutral. A composing BFF is a codegen project.
VITE v7.1.2 ready in 412 ms ➜ Local: http://localhost:5173/ ➜ press h + enter to show help [hmr] connected.
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
keycloak-1 | Keycloak 27.0.1 started keycloak-1 | Listening on: http://0.0.0.0:8080 keycloak-1 | Running in development mode
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.
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.
info: Now listening on: https://localhost:7164 info: BFF management endpoints → /bff /bff/user /bff/login /bff/logout info: MapRemoteBffApiEndpoint /api → https://localhost:7245/api ↑ path-preserving. codegen safe. info: x-csrf required on all remote routes
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
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.
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.
✓ 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.
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].
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]
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.
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.
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]
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.
| = | OpenAPI document | No change if the proxy is path-preserving [3] |
| = | Generated client code | No change. baseUrl becomes relative; credentials: 'include' + X-CSRF: 1 added [15] |
| = | Codegen pipeline | No change — .NET 10 build-time generation writes the spec to disk; it never traverses the BFF [9] |
| − | Client auth code | Simpler. No token store, no refresh interceptor, no bearer scheme in the spec [2] |
| − | CORS config in the API | Removed [1] |
| = | HMR / F5 loop | Unchanged — provided Vite is the proxy, not the proxied [7] |
| − | HTTPS dev certs | Not 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 config | redirect_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 BFF | Don'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.