Securing a React SPA with the Backend-for-Frontend Pattern
Introduction
The Getting Started guide built two applications talking OpenID Connect: a provider and an ASP.NET MVC client. That client had it easy. It runs on the server, so the tokens it receives live on the server, out of reach of the browser.
A single-page application does not have that luxury. Its code runs in the browser, and the browser is a hostile place to keep a token. Any script that runs on the page (an XSS bug, a compromised npm dependency, a third-party tag) can read a token held in localStorage or JavaScript memory and send it anywhere. That is not an exotic edge case. It is the default threat model for modern frontend code.
The Backend-for-Frontend (BFF) pattern answers this by refusing to put tokens in the browser at all. A thin .NET backend becomes the real OpenID Connect client. It runs the authorization-code flow, keeps the tokens in an encrypted, HttpOnly cookie the SPA cannot read, and reverse-proxies the SPA's API calls with the access token attached server-side. This guide builds exactly that with Abblix OIDC Server: a React SPA, a .NET BFF, and a protected API behind it.
The design follows the BFF architecture defined by the IETF in OAuth 2.0 for Browser-Based Applications, section 6.1. Where that document sets a normative requirement (a confidential client, the authorization-code grant, and Secure, HttpOnly, SameSite cookies), this guide points it out and the sample implements it.
This is an advanced guide. It assumes you have worked through the Getting Started guide and have a running OpenIDProviderApp.
TL;DR
You will build a React single-page application that logs in through Abblix OIDC Server without ever touching a token. A small .NET backend does the OpenID Connect work and holds the session in an HttpOnly cookie. Then you will add a protected Web API and have the SPA call it through the BFF, which attaches the access token on the server side. By the end you will have a three-part solution: the provider from Getting Started, a React SPA with its BFF backend, and a scope-protected API.
What You'll Build
You will reuse OpenIDProviderApp from Getting Started and register one new client and one API resource in its configuration, then create two new projects.
BffSample - a React SPA with a .NET Backend-for-Frontend:
- Serves the React application and acts as its OpenID Connect client
- Keeps access and ID tokens in an HttpOnly session cookie, never in the browser
- Exposes a small
/bffAPI for session, login, and logout - Reverse-proxies the SPA's data calls to a protected API, attaching the access token server-side
ApiSample - a protected resource API:
- Validates access tokens issued by
OpenIDProviderApp - Requires the
weatherscope before returning data - Serves a
/weatherforecastendpoint the SPA reaches only through the BFF
Here's the path we'll take:
- Scaffold the BFF application (a .NET backend plus a Vite React SPA) from stock templates
- Register the SPA as a client of
OpenIDProviderApp - Configure OpenID Connect in the BFF backend and expose the
/bffsession API - Authenticate from React through the BFF, with no token in the browser
- Add a protected API, register it as a resource, and proxy calls to it through the BFF
- Run the complete three-project solution and trace the flow end to end
Before You Start
You need the result of the Getting Started guide: a working OpenIDProviderApp on https://localhost:5001. If you would rather start from a finished copy, clone Abblix/Oidc.Server.GettingStarted and follow along against those projects.
The SPA is built and served with Vite, so you also need Node.js installed. The .NET backend restores the npm packages on its first build.
Scaffold the BFF Application
The BFF is two things in one project: a .NET backend and a React SPA built with Vite, the SPA living in a ClientApp subfolder of the backend. We build both from stock templates, no Abblix-specific scaffolding, so every step is reproducible with the standard toolchain.
Create the .NET backend and add it to the solution:
dotnet new web -n BffSample
dotnet sln add ./BffSample/BffSample.csproj
Set the backend port to 5003 to match the client registration you will add next. Open BffSample/Properties/launchSettings.json, find the https profile, and set applicationUrl to https://localhost:5003. The template also generates an http profile, and dotnet run starts the first profile it finds, so delete the http profile (or always run with --launch-profile https). Otherwise the app comes up on plain HTTP, SpaProxy never starts, and the OIDC redirect fails against the https://localhost:5003 callback you registered.
Create the React application with Vite inside the backend project:
cd BffSample
npm create vite@latest ClientApp -- --template react-ts
cd ClientApp && npm install
cd ../..
Add the three packages the backend needs:
dotnet add BffSample package Microsoft.AspNetCore.Authentication.OpenIdConnect
dotnet add BffSample package Microsoft.AspNetCore.SpaProxy
dotnet add BffSample package Yarp.ReverseProxy
Microsoft.AspNetCore.Authentication.OpenIdConnect makes the backend an OIDC client. Microsoft.AspNetCore.SpaProxy launches and proxies the Vite dev server in development, so you run one project and still get SPA live reload. Yarp.ReverseProxy forwards the SPA's API calls to the protected API later in this guide.
Wire the SPA into the backend build
Tell the backend where the SPA lives and how to launch its dev server. Add these properties to the <PropertyGroup> in BffSample.csproj:
<SpaRoot>ClientApp\</SpaRoot>
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
<SpaProxyServerUrl>https://localhost:3000</SpaProxyServerUrl>
<DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes>
Then add build targets so the SPA's packages restore on build and the SPA is built into wwwroot on publish:
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build"
Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
</Target>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />
<ItemGroup>
<DistFiles Include="$(SpaRoot)dist\**" />
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
Finally, let the backend start the dev server. In launchSettings.json, under the https profile's environmentVariables, add:
"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
Serve the SPA over HTTPS in development
OpenID Connect redirects run over TLS, so the Vite dev server has to serve HTTPS with a certificate the browser trusts. The simplest way is to reuse the ASP.NET Core development certificate. This is the local development certificate only, never a production or public key. Replace ClientApp/vite.config.ts with a config that exports that certificate the first time and serves HTTPS on port 3000:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import fs from 'fs';
import path from 'path';
import { env } from 'process';
import { execSync } from 'child_process';
const certFolder = (env.APPDATA ?? '') !== '' ? `${env.APPDATA}/ASP.NET/https` : `${env.HOME}/.aspnet/https`;
const certPath = path.resolve(certFolder, 'localhost.pem');
const keyPath = path.resolve(certFolder, 'localhost.key');
if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) {
execSync(`dotnet dev-certs https --export-path ${certPath} --format Pem --no-password`);
}
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: true,
https: {
cert: fs.readFileSync(certPath, 'utf-8'),
key: fs.readFileSync(keyPath, 'utf-8'),
},
},
});
The SpaProxyServerUrl in the csproj and this port must agree; both are 3000.
Register the SPA as a Client
OpenIDProviderApp only issues tokens to clients it knows. Open its Program.cs and add a second ClientInfo to the Clients array inside AddOidcServices, alongside the test_client from Getting Started:
new ClientInfo("bff_sample") {
ClientSecrets = [new ClientSecret { Sha512Hash = SHA512.HashData(Encoding.UTF8.GetBytes("secret")) }],
TokenEndpointAuthMethod = ClientAuthenticationMethods.ClientSecretPost,
AllowedGrantTypes = [GrantTypes.AuthorizationCode],
PkceRequired = true,
RedirectUris = [new Uri("https://localhost:5003/signin-oidc", UriKind.Absolute)],
PostLogoutRedirectUris = [new Uri("https://localhost:5003/signout-callback-oidc", UriKind.Absolute)],
},
The client uses the authorization-code flow with PKCE, the same secure default the MVC client used. The one thing that differs is the port: 5003, where the BFF backend runs. signin-oidc and signout-callback-oidc are the default callback paths of Microsoft's OpenID Connect middleware, so you do not have to configure them on the client side.
The client secret here is hard-coded for the sample only. In a real deployment, keep secrets out of source with dotnet user-secrets, environment variables, or a secret store, exactly as you would for any credential.
Configure OpenID Connect in the BFF Backend
The backend is a plain OpenID Connect client: it authenticates with a cookie and challenges through Abblix OIDC Server. Put the settings in BffSample/appsettings.json:
{
"Authentication": {
"DefaultScheme": "Cookies",
"DefaultChallengeScheme": "OpenIdConnect"
},
"Cookies": {
"Cookie": {
"Name": "__Host-Bff",
"SecurePolicy": "Always",
"HttpOnly": true,
"SameSite": "Strict"
}
},
"OpenIdConnect": {
"SignInScheme": "Cookies",
"SignOutScheme": "Cookies",
"SaveTokens": true,
"Scope": [ "openid", "profile", "email" ],
"MapInboundClaims": false,
"ResponseType": "code",
"ResponseMode": "query",
"UsePkce": true,
"GetClaimsFromUserInfoEndpoint": true
}
}
SaveTokens is the setting that makes the pattern work: it tells the middleware to keep the access and ID tokens in the authentication session (the cookie), where the backend can read them later and the browser cannot. Keep the environment-specific values (the authority and the credentials) in appsettings.Development.json:
{
"OpenIdConnect": {
"Authority": "https://localhost:5001",
"ClientId": "bff_sample",
"ClientSecret": "secret"
}
}
Now wire it up in Program.cs. Cookie authentication is the primary scheme; OpenID Connect is the scheme used to challenge:
var builder = WebApplication.CreateBuilder(args); // the template's first line
var configuration = builder.Configuration;
builder.Services
.AddAuthorization()
.AddAuthentication(options => configuration.Bind("Authentication", options))
.AddCookie(options => configuration.Bind("Cookies", options))
.AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
builder.Services.AddControllers();
builder.Services.AddHttpForwarder();
Every scheme is bound straight from configuration, so the cookie hardening lives in appsettings.json next to the rest, not hard-coded in Program.cs.
A Note on the Session Cookie
The cookie this backend issues is the whole security boundary of the pattern, so it carries the specification's cookie requirements directly. Secure (a MUST) keeps it off plaintext connections. HttpOnly (a MUST) keeps JavaScript from reading the cookie, so a script on the page cannot copy the session and replay it elsewhere. SameSite=Strict (a SHOULD, and the CSRF defense here) means the cookie rides only same-site requests. The __Host- name prefix binds the cookie to this exact host and forbids a Domain attribute or a non-root path, so no sibling subdomain can set or read it.
SameSite=Strict works cleanly in this guide because the SPA, the BFF, and the provider all share the localhost site. In production, match it to your topology:
- If the BFF owns its whole site (nothing else shares its registrable domain, the eTLD+1): keep
SameSite=Strictand you are done. On its own it is a complete CSRF defense, because the cookie never rides a request from another site. - If the BFF shares its site with other apps (sibling subdomains like
app.example.comandadmin.example.com):SameSite=Strictis not enough, because a sibling is same-site and the cookie rides its requests too. KeepStrictand also require a custom request header that the SPA sends and the/bffendpoints check (see Next Steps), so a sibling cannot forge calls. - If your provider is on a different site than the BFF (for example
login.example.comvsapp.example.com): nothing to change on the session cookie. The OpenID Connect middleware sets its own short-lived,Secure,HttpOnly,SameSite=Nonecorrelation cookies so they survive the cross-site redirect; they carry no session authority, and your__Host-Bffcookie staysStrict.
The browser holds only this opaque cookie; the tokens it stands in for never leave the server.
The BFF API: session, login, logout
The SPA never speaks OpenID Connect. It speaks to its own backend over three small endpoints. Add a controller at BffSample/Controllers/BffController.cs:
[ApiController]
[Route("[controller]")]
public class BffController : Controller
{
public const string CorsPolicyName = "Bff";
[HttpGet("check_session")]
[EnableCors(CorsPolicyName)]
public ActionResult<IDictionary<string, string>> CheckSession()
{
// 401 tells the SPA to start a login; otherwise return the user's claims
if (User.Identity?.IsAuthenticated != true)
return Unauthorized();
return User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value);
}
[HttpGet("login")]
public ActionResult Login()
{
// Challenge starts the authorization-code flow and returns to the SPA afterward
return Challenge(new AuthenticationProperties { RedirectUri = Url.Content("~/") });
}
// GET so the browser can navigate here: RP-initiated logout needs a top-level redirect
[HttpGet("logout")]
public IActionResult Logout() => SignOut(
new AuthenticationProperties { RedirectUri = "/" },
CookieAuthenticationDefaults.AuthenticationScheme,
OpenIdConnectDefaults.AuthenticationScheme);
}
check_session is the SPA's way of asking "am I logged in, and who am I?". A 401 is the signal to begin a login rather than an error to display. login issues a Challenge, which the OpenID Connect middleware turns into a redirect to OpenIDProviderApp. logout signs out both schemes: it clears the BFF session cookie and initiates an RP-initiated logout at the provider, so the user is signed out of the identity provider too, not only this application. It is a GET because the browser has to navigate to it and follow the redirect to the provider's end-session endpoint and back; a fetch cannot drive that redirect. Notice what is absent: no token handling, no redirect URLs, no PKCE code. The middleware owns all of that.
Configure CORS for the SPA
In production the backend serves the built SPA from its own origin, so the SPA and the /bff API share an origin and no CORS is involved. In development the Vite dev server runs on its own origin (https://localhost:3000) while the API stays on 5003, so the browser needs permission to send the session cookie across origins. The only cross-origin calls are the credentialed GETs (check_session and the proxied data calls); login and logout are top-level navigations, which CORS does not govern. Allow exactly that origin, in appsettings.Development.json:
{
"CorsSettings": {
"AllowedOrigins": [ "https://localhost:3000" ]
}
}
Register the policy in Program.cs:
builder.Services.AddCors(
options => options.AddPolicy(
BffController.CorsPolicyName,
policyBuilder =>
{
var allowedOrigins = configuration.GetSection("CorsSettings:AllowedOrigins").Get<string[]>();
if (allowedOrigins is { Length: > 0 })
policyBuilder.WithOrigins(allowedOrigins);
policyBuilder
.WithMethods(HttpMethods.Get)
.AllowCredentials();
}));
AllowCredentials is what lets the cross-origin fetch carry the session cookie. Without it the SPA would look permanently logged out in development.
Do not mistake this CORS policy for a CSRF defense. A cross-site GET is CORS-safelisted: the browser sends it without a preflight and only withholds the response, so CORS stops an attacker from reading data, not from triggering a request. The CSRF defense is SameSite=Strict on the session cookie, which keeps the cookie off cross-site requests in the first place.
Authenticate from the React SPA
The React side needs one piece of shared machinery: a context that knows how to talk to the /bff API and how to react to a 401. Create BffSample/ClientApp/src/components/Bff.tsx:
export const BffProvider: FC<BffProviderProps> = ({ baseUrl, children }) => {
const [user, setUser] = useState<UserClaims | null>(null);
const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
// every call includes credentials so the session cookie travels with it
const fetchBff = useCallback(
async (endpoint: string, options: RequestInit = {}): Promise<Response> =>
fetch(`${normalizedBaseUrl}/${endpoint}`, { credentials: 'include', ...options }),
[normalizedBaseUrl]
);
const login = useCallback(
() => window.location.replace(`${normalizedBaseUrl}/login`),
[normalizedBaseUrl]
);
const checkSession = useCallback(async (): Promise<void> => {
const response = await fetchBff('check_session');
if (response.ok)
setUser(await response.json()); // logged in: keep the claims
else if (response.status === 401)
login(); // not logged in: start a login
}, [fetchBff, login]);
// full RP-initiated logout: navigate so the browser can follow the provider's end-session redirect
const logout = useCallback(
() => window.location.replace(`${normalizedBaseUrl}/logout`),
[normalizedBaseUrl]
);
useEffect(() => { checkSession(); }, [checkSession]);
return (
<BffContext.Provider value={{ user, fetchBff, checkSession, login, logout }}>
{children}
</BffContext.Provider>
);
};
export const useBff = (): BffContextProps => useContext(BffContext);
The whole login decision lives in checkSession: call /bff/check_session on load, render the claims if it succeeds, and redirect into the login if it answers 401. The fetchBff helper always passes credentials: 'include' so the session cookie rides along on every request. A component then reads the claims through the useBff hook, in src/components/UserClaims.tsx:
export const UserClaims: React.FC = () => {
const { user } = useBff();
if (!user) return <p>Checking your session...</p>;
return (
<dl>
{Object.entries(user).map(([claim, value]) => (
<React.Fragment key={claim}>
<dt>{claim}</dt>
<dd>{String(value)}</dd>
</React.Fragment>
))}
</dl>
);
};
Wrap the app in the provider, pointing it at the backend's /bff base, in src/App.tsx:
const App: React.FC = () => (
<BffProvider baseUrl="https://localhost:5003/bff">
<UserClaims />
</BffProvider>
);
Finally, in Program.cs, build the request pipeline. The fallback that serves the SPA is guarded with RequireAuthorization:
var app = builder.Build();
if (!app.Environment.IsDevelopment())
app.UseHsts();
app.UseHttpsRedirection();
app.UseRouting();
app.UseStaticFiles();
app.UseCors(BffController.CorsPolicyName);
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapFallbackToFile("index.html").RequireAuthorization();
app.Run();
How that guard behaves depends on who serves the SPA. In production the backend serves index.html from wwwroot, so RequireAuthorization challenges an unauthenticated first visit before the SPA ever loads. In development the Vite dev server serves the SPA (SpaProxy sends the browser there), so the guard does not fire on first load; instead the SPA runs, calls check_session, gets a 401, and starts the login from the client. Both paths end at the same session cookie.
Run and See Your Claims
Start the provider and the BFF in separate terminals from the solution root:
dotnet run --project OpenIDProviderApp
dotnet run --project BffSample
The BFF launches the Vite dev server for you. Open https://localhost:5003, and the flow unfolds: the SPA loads, calls /bff/check_session, gets a 401, and sends you to OpenIDProviderApp to log in. Use the seeded credentials from Getting Started (john.doe@example.com / Jd!2024$3cur3). You land back on the SPA, which now shows your claims: sub, name, email, and the session id. At no point did a token reach the browser.
If the browser refuses the connection, trust the ASP.NET Core development certificate with dotnet dev-certs https --trust and restart the browser. The Vite dev server serves HTTPS from a copy of that same certificate; if you regenerated your dev certificate at some point, delete the exported localhost.pem and localhost.key under %APPDATA%\ASP.NET\https (or ~/.aspnet/https) so the dev server exports a fresh copy.
Add a Protected API
Showing claims proves the login works. The reason to run a BFF, though, is to call protected APIs without exposing the token to the browser. Add an API and have the SPA reach it through the BFF.
Create ApiSample
Create a Web API project and set its port to 5004:
dotnet new webapi -n ApiSample
dotnet sln add ./ApiSample/ApiSample.csproj
Add the JWT bearer package so the API can validate access tokens:
dotnet add ApiSample package Microsoft.AspNetCore.Authentication.JwtBearer
The API validates tokens issued by OpenIDProviderApp and requires the weather scope before serving data. Configure token validation in ApiSample/appsettings.Development.json:
{
"JwtBearerAuthentication": {
"Authority": "https://localhost:5001",
"MapInboundClaims": false,
"TokenValidationParameters": {
"ValidTypes": [ "at+jwt" ],
"ValidAudience": "https://localhost:5004",
"ValidIssuer": "https://localhost:5001"
}
}
}
ValidTypes pins the token to the RFC 9068 access-token type (at+jwt), so an ID token cannot be replayed against the API. The audience is the API's own URL, which is how the provider marks a token as meant for this resource. Then wire authentication and a scope policy in Program.cs (the dotnet new webapi template already provides builder and app; add var configuration = builder.Configuration; near the top if it is not there):
builder.Services
.AddAuthentication()
.AddJwtBearer(options => configuration.Bind("JwtBearerAuthentication", options));
const string policyName = "WeatherApi";
builder.Services.AddAuthorization(options => options.AddPolicy(policyName, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context =>
{
var scopeValue = context.User.FindFirstValue("scope");
if (string.IsNullOrEmpty(scopeValue))
return false;
var scope = scopeValue.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return scope.Contains("weather", StringComparer.Ordinal);
});
}));
Protect the endpoint with the policy:
app.MapGet("/weatherforecast", () => /* return forecasts */)
.RequireAuthorization(policyName);
Register the API as a Resource
The provider must know the API exists so it can issue tokens scoped for it. In OpenIDProviderApp's Program.cs, add a ResourceDefinition inside AddOidcServices:
options.Resources =
[
new ResourceDefinition(
new Uri("https://localhost:5004", UriKind.Absolute),
new ScopeDefinition("weather")),
];
This ties the weather scope to the audience https://localhost:5004. When a client asks for weather, the access token the provider issues carries that audience, which is exactly what the API checks.
Ask for the weather scope in the BFF
The BFF has to request the scope for the token to carry it. Add weather to the Scope array in BffSample/appsettings.json:
"Scope": [ "openid", "profile", "email", "weather" ],
and point the BFF at the resource it will proxy to, in appsettings.Development.json:
"OpenIdConnect": {
"Resource": "https://localhost:5004",
"Authority": "https://localhost:5001",
"ClientId": "bff_sample",
"ClientSecret": "secret"
}
Proxy API Calls Through the BFF
Now the load-bearing part. The SPA calls /bff/weatherforecast on its own origin; the BFF forwards that to the API, and attaches the access token from the session as it goes. The browser is never given the token. Add the forwarder to the end of BffSample's Program.cs, before app.Run():
const string key = "OpenIdConnect:Resource";
var destinationPrefix = configuration.GetValue<string>(key)
?? throw new InvalidOperationException($"The value {key} must be set");
app.MapForwarder(
"/bff/{**catch-all}",
destinationPrefix,
builderContext =>
{
// strip the /bff prefix so /bff/weatherforecast reaches /weatherforecast
builderContext.AddPathRemovePrefix("/bff");
builderContext.AddRequestTransform(async transformContext =>
{
// strip the session cookie: the resource server authenticates on the bearer token
// alone and must never see the BFF's session (draft section 6.1.1, step K)
transformContext.ProxyRequest.Headers.Remove("Cookie");
// read the access token from the session and attach it server-side
var accessToken = await transformContext.HttpContext
.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
transformContext.ProxyRequest.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
});
})
.RequireAuthorization();
GetTokenAsync reads the access token that SaveTokens stashed in the cookie session at login. The transform first removes the session cookie, because the resource server has no business seeing it and authenticates on the bearer token alone, then puts the token in the Authorization header of the outbound call. RequireAuthorization means an unauthenticated request never even reaches the API. The token exists only on the two hops the browser cannot see: cookie to backend, backend to API.
Call the API from the SPA
With the proxy in place, the SPA fetches the forecast the same way it checks the session, through fetchBff, in src/components/WeatherForecast.tsx:
export const WeatherForecast: React.FC = () => {
const { fetchBff } = useBff();
const [forecasts, setForecasts] = useState<Forecast[]>([]);
useEffect(() => {
fetchBff('weatherforecast')
.then(response => response.json())
.then(setForecasts);
}, [fetchBff]);
return (
<table>
<thead><tr><th>Date</th><th>Temp. (C)</th><th>Summary</th></tr></thead>
<tbody>
{forecasts.map((f, i) => (
<tr key={i}><td>{f.date}</td><td>{f.temperatureC}</td><td>{f.summary}</td></tr>
))}
</tbody>
</table>
);
};
Add it next to UserClaims inside the provider in App.tsx, and the SPA now shows live data from a protected API it reached without ever holding a token.
When the access token expires
This sample requests openid profile email weather and no offline_access, so the provider issues no refresh token and the middleware does not refresh on its own. GetTokenAsync returns the access token saved at login; once it expires, the __Host-Bff cookie is still valid (the user looks logged in) but every proxied call starts coming back 401 from the API. For anything past a demo, request offline_access, keep the refresh token in the session (it stays server-side like the others), and refresh before the access token expires, or treat a proxied 401 as the signal to re-challenge.
Run the Complete Solution
Three projects make up the finished picture. Start each in its own terminal from the solution root:
dotnet run --project OpenIDProviderApp # provider, https://localhost:5001
dotnet run --project ApiSample # protected API, https://localhost:5004
dotnet run --project BffSample # BFF + SPA, https://localhost:5003
Open https://localhost:5003, sign in, and watch the whole chain: the SPA authenticates through the BFF, the provider issues a token scoped for the weather API, the BFF stores it in the cookie, and every forecast request is proxied to ApiSample with that token attached server-side. Open your browser's developer tools and look: the token is in none of them.
Troubleshooting Common Issues
The SPA is stuck on "Checking your session"
Symptom: The page loads but never shows claims or redirects to login.
Cause: The cross-origin check_session call is failing, usually a CORS or cookie problem in development.
Fix: Confirm that CorsSettings:AllowedOrigins lists the exact Vite origin (https://localhost:3000), that the CORS policy calls AllowCredentials, and that fetchBff sends credentials: 'include'. If the browser console shows Failed to fetch with no CORS message, the browser may distrust the backend's development certificate for cross-origin requests until you open https://localhost:5003 once in a top-level tab; do that, accept the certificate, then reload the SPA.
Redirect URI mismatch after login
Symptom: The provider shows "The redirect_uri in the request does not match a registered redirect URI".
Cause: The bff_sample client registration does not match the BFF's actual port or callback path.
Fix: Verify the client's RedirectUris is exactly https://localhost:5003/signin-oidc. A port of 5002 instead of 5003, or a different callback path, will fail.
The weather call returns 401 or 403
Symptom: Login works and claims appear, but /bff/weatherforecast fails.
Cause: The access token lacks the weather scope, or the API cannot validate it.
Fix: Check three links in the chain: weather is in the BFF's Scope array, the provider registers the weather scope against the https://localhost:5004 resource, and the API's ValidAudience and ValidIssuer match the resource URL and the provider URL.
The forecast is empty but no error appears
Symptom: The table renders with no rows.
Cause: ApiSample is not running, so the proxied call has nowhere to go.
Fix: Start ApiSample on https://localhost:5004, and confirm the BFF's OpenIdConnect:Resource points at that same URL.
Certificate trust warnings in the browser
Symptom: "Your connection is not private", on 5003 or 3000.
Fix: Run dotnet dev-certs https --trust and restart the browser. If only the Vite origin (3000) fails, delete the exported localhost.pem and localhost.key under %APPDATA%\ASP.NET\https so the dev server re-exports the current certificate. For Chrome, you can also click the error page and type thisisunsafe in development only.
Access the Complete Solution on GitHub
The finished BffSample and ApiSample, together with the OpenIDProviderApp they build on, live in Abblix/Oidc.Server.GettingStarted. Clone it to compare against a working copy, or to run the whole flow before wiring your own.
What the BFF pattern does not solve
Keeping tokens off the browser closes the attack this guide opened with: a script on the page cannot read or exfiltrate a token, and there is no refresh token in the browser to steal. That is a real and large reduction in blast radius, and it is what the IETF draft credits the pattern with.
It does not make an XSS-compromised page safe. A script running on the page can still call /bff/... with the session cookie, and the BFF will faithfully attach the token and proxy the request. The attacker never sees the token, but rides the authenticated session as a confused deputy for as long as the page is open. The BFF converts token theft into in-session request forgery: narrower, bounded to the endpoints the BFF proxies, but not nothing. Preventing script execution in the first place (a strict Content-Security-Policy, dependency hygiene, output encoding) stays mandatory. The companion article works through this threat model in full.
Conclusion
You have taken the hardest client to secure, a single-page application, and given it a login that keeps tokens off the browser. The React code never sees an access token, an ID token, or a refresh token. It sees a session cookie it cannot read and a small /bff API on its own origin. The backend does the OpenID Connect work, holds the tokens, and proxies data calls with the access token attached on the server, where no browser script can read or exfiltrate it.
The same three-part shape (SPA, its BFF, and a scope-protected API behind it) is the one you carry into production; the architecture does not change. What you add before shipping is operational: real secret storage for the client credential, a deliberate cookie lifetime, and, because the tokens live inside the encrypted cookie, a persisted and shared ASP.NET Core Data Protection key ring so the cookie survives a restart and decrypts across every instance.
Next Steps to Consider
- Give the session cookie a deliberate
ExpireTimeSpanand sliding expiration (the sample already setsSecure,HttpOnly,SameSite=Strict, and the__Host-prefix), and review it against the Production Hardening Checklist. - Persist the Data Protection key ring to shared storage so the token-bearing cookie survives restarts and decrypts across every instance.
- If the BFF shares a registrable domain with other apps, add a required custom request header (for example
X-Requested-With) that the SPA sends and the/bffendpoints check, so a same-site sibling cannot forge state-changing calls, and restrict the proxied HTTP methods per endpoint rather than forwarding every verb. - Add a second protected API and a second scope, and watch the same proxy pattern carry the right token to each.
- Read Modern Authentication on .NET: OpenID Connect, BFF, SPA for the threat-model reasoning behind every choice this guide made.