# Securing a React SPA with the Backend-for-Frontend Pattern

## Introduction

The [Getting Started guide](https://www.abblix.com/en/docs/getting-started-guide) built two applications talking [OpenID Connect](https://www.abblix.com/en/docs/glossary-overview#openid-connect): a provider and an [ASP.NET MVC](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/glossary-overview#bff) (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](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/glossary-overview#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](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#name-backend-for-frontend-bff). Where that document sets a normative requirement (a [confidential client](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/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 `/bff` API 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 `weather` scope before returning data
- Serves a `/weatherforecast` endpoint 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 `/bff` session 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](https://www.abblix.com/en/docs/getting-started-guide): a working `OpenIDProviderApp` on `https://localhost:5001`. If you would rather start from a finished copy, clone [Abblix/Oidc.Server.GettingStarted](https://github.com/Abblix/Oidc.Server.GettingStarted) and follow along against those projects.

The SPA is built and served with [Vite](https://vitejs.dev/), so you also need [Node.js](https://nodejs.org/) 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:

```bash
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:

```bash
cd BffSample
npm create vite@latest ClientApp -- --template react-ts
cd ClientApp && npm install
cd ../..
```

Add the three packages the backend needs:

```bash
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`:

```xml
<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:

```xml
<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:

```json
"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`:

```ts
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:

```csharp
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](https://www.abblix.com/en/docs/glossary-overview#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.

:::warning[IMPORTANT]
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`:

```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": "form_post",
    "UsePkce": true,
    "GetClaimsFromUserInfoEndpoint": true
  }
}
```

The `ResponseMode` is `form_post`, not the more common `query`. With `form_post` the provider hands the authorization code back in a small auto-submitting form that POSTs straight to the backend's `signin-oidc` endpoint, so the code never sits in the browser's address bar, its history, or a `Referer` header on the way in. Microsoft's middleware already issues the short-lived, `SameSite=None` correlation cookies that let that cross-site POST land, so nothing else has to change.

`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`:

```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:

```csharp
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](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#name-cookie-security) 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](https://www.abblix.com/en/docs/glossary-overview#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=Strict` and 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.com` and `admin.example.com`): `SameSite=Strict` is not enough, because a sibling is same-site and the cookie rides its requests too. Keep `Strict` and also require a custom request header that the SPA sends and the `/bff` endpoints 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.com` vs `app.example.com`): nothing to change on the session cookie. The OpenID Connect middleware sets its own short-lived, `Secure`, `HttpOnly`, `SameSite=None` correlation cookies so they survive the cross-site callback; they carry no session authority, and your `__Host-Bff` cookie stays `Strict`.

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`:

```csharp
[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](https://www.abblix.com/en/docs/glossary-overview#rp-initiated-logout) at the provider, so the user is signed out of the [identity provider](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/glossary-overview#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 `GET`s (`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`:

```json
{
  "CorsSettings": {
    "AllowedOrigins": [ "https://localhost:3000" ]
  }
}
```

Register the policy in `Program.cs`:

```csharp
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`:

```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`:

```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`:

```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`:

```csharp
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:

```bash
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.

:::warning[IMPORTANT]
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`:

```bash
dotnet new webapi -n ApiSample
dotnet sln add ./ApiSample/ApiSample.csproj
```

Add the JWT bearer package so the API can validate access tokens:

```bash
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`:

```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](https://www.abblix.com/en/docs/glossary-overview#id-token-identity-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):

```csharp
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:

```csharp
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`:

```csharp
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`:

```json
"Scope": [ "openid", "profile", "email", "weather" ],
```

and point the BFF at the resource it will proxy to, in `appsettings.Development.json`:

```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()`:

```csharp
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`:

```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](https://www.abblix.com/en/docs/glossary-overview#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:

```bash
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](https://github.com/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](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#name-backend-for-frontend-bff).

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](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/glossary-overview#csp), dependency hygiene, output encoding) stays mandatory. [Modern Authentication on .NET: BFF and SPA](https://www.abblix.com/en/docs/net-authentication-openid-connect-bff-spa) 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 `ExpireTimeSpan` and sliding expiration (the sample already sets `Secure`, `HttpOnly`, `SameSite=Strict`, and the `__Host-` prefix), and review it against the [Production Hardening Checklist](https://www.abblix.com/en/docs/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 `/bff` endpoints 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](https://www.abblix.com/en/docs/net-authentication-openid-connect-bff-spa) for the threat-model reasoning behind every choice this guide made.
