Zum Inhalt springen
Diese Seite wurde noch nicht übersetzt.

Modern Authentication on .NET: OpenID Connect, BFF, SPA

Introduction

Every Single Page Application that keeps OAuth tokens in the browser is one XSS away from handing them to an attacker. This is not an exotic edge case: hundreds of transitive npm dependencies, third-party scripts, and the sheer size of modern frontend code make script injection a permanent line in any realistic threat model. The OAuth 2.0 and OpenID Connect protocols have evolved in response (old flows deprecated, new mitigations added) but no browser-side mechanism changes the basic fact: whatever your code can reach, injected code can reach too.

The Backend-For-Frontend (BFF) architectural pattern answers this by moving tokens out of the browser entirely. The backend becomes the OpenID Connect client, obtains and holds the tokens, and the SPA works with an ordinary authenticated session. This article states the case for that in short and then builds a working implementation on .NET and React, step by step; the pattern itself, its alternatives and the objections it attracts are treated separately.

TL;DR

Tokens stored in the browser can be used by any script that runs in your origin, and neither refresh token rotation nor DPoP changes that for a compromised frontend. The OAuth 2.0 for Browser-Based Applications guidance recommends the BFF pattern: keep the tokens on the backend, give the browser only a session cookie.

The practical part assembles a complete setup from three pieces: an OpenID Connect server on Abblix OIDC Server, a .NET backend with Microsoft.AspNetCore.Authentication.OpenIdConnect plus YARP as a reverse proxy, and a React SPA. The finished code is in Abblix/Oidc.Server.GettingStarted.

Historical Context

OAuth 2.0 (RFC 6749, 2012) gave third-party applications a way to obtain limited access to user resources without handling the user's credentials. OpenID Connect (2014) added the authentication layer on top: a standard way for a client to verify the user's identity and receive it as an ID token in JWT format. Both protocols have been evolving ever since, and so has the threat model they face.

Evolution of the Threat Model

As SPAs grew in capability and popularity, Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF) attacks against them became more widespread. Since SPAs often interact with the server via APIs, how an SPA stores and uses access and refresh tokens has become a central attack surface.

The protocols accumulate capabilities faster than the outdated ones are retired, so the specification surface today mixes current best practice with approaches already considered unsafe, and an SPA developer has to tell one from the other.

In particular, the Implicit Flow is now considered obsolete; for any client type (SPA, mobile, or desktop) the strong recommendation is Authorization Code Flow with Proof Key for Code Exchange (PKCE). The OAuth 2.0 Security Best Current Practice (RFC 9700) codifies this recommendation, and the upcoming OAuth 2.1 goes further: the implicit and password grants are removed from the specification entirely, and PKCE becomes mandatory. There is a full walkthrough in OpenID Connect flows explained, which describes the evolution of authentication approaches, the associated risks and ways to mitigate them.

Security of Modern SPAs

Why are modern SPAs still considered vulnerable, even when using the Authorization Code Flow with PKCE?

A React, Vue or Angular application carries hundreds of transitive dependencies, and auditing all of them is beyond what almost any team does. Malicious JavaScript that reaches the page, through a Cross-Site Scripting (XSS) bug or a compromised package, runs with exactly the privileges of the legitimate code: it reads what the application reads, calls what the application calls, and can start its own authentication flow to mint tokens of its own.

The mitigations usually offered against this do not close it. Refresh token rotation catches a token replayed from elsewhere, not an attacker inside the origin who steals each refreshed token and then makes sure the application never uses it, so no reuse is ever detected - a point Philippe De Ryck makes directly in "Breaking and Securing OAuth 2.0 in Frontends" (YOW! 2025). DPoP binds a token to a non-extractable key, and it is a real defense against a token exfiltrated and replayed from another machine; what it does not do is move the trust boundary, because injected code can sign proofs with that same key without ever extracting it. Encrypting the token into an HttpOnly cookie hides it from JavaScript while leaving its full scope attached to every request. Each of these makes theft harder; none changes the fact that the token sits in the environment the attacker shares.

The Backend-For-Frontend pattern changes that fact instead of working around it: the tokens are obtained and held by the server side and never cross into the browser, which keeps only a session cookie. That page covers the architecture itself, how it compares with a token-mediating backend, the endpoints a backend has to expose, and each of these objections at length. The rest of this article builds one on .NET.

The interaction between the authorization server (OP), the client (RP) implementing the BFF pattern, and a third-party API (Resource Server) looks like this: Authorization Code Flow with BFF

The tokens live in the backend's authentication session and are never exposed to the SPA: no script in the page can read one, take one away, or use one from another machine. Where that session rides in the cookie rather than in a server-side store, the cookie is encrypted, which is why the configuration below sets it up that way. Session lifetime and token audience are decided in one place. The frontend gets simpler because it no longer runs a protocol, and the deployment gets one more component to run.

What this does not do is stop XSS. Injected code can still act as the user through the session cookie for as long as the session lasts; the BFF pattern page argues that limit and the objections around it in full.

Implementing the Backend-For-Frontend Pattern on the .NET Platform

Let's assume we already have a configured OpenID Connect server and we need to develop an SPA that works with a backend, implement authentication using OpenID Connect, and organize the interaction between the server and client parts using the BFF pattern.

According to the document OAuth 2.0 for Browser-Based Applications, the BFF architectural pattern assumes that the backend acts as an OpenID Connect client, uses Authorization Code Flow with PKCE for authentication, obtains and stores access and refresh tokens on its side and never passes them to the SPA side in the browser. The BFF pattern also defines a backend API with four main endpoints:

BFF Endpoints

  • Check Session: reports whether an active authentication session exists. The SPA calls it asynchronously (fetch) on startup and, if successful, receives information about the signed-in user; otherwise it proceeds to authentication. The name is this pattern's convention and has nothing to do with the OIDC Session Management check_session_iframe: it is the BFF's own session probe.

  • Login: starts the authentication flow. When Check Session reports no session, the SPA redirects the browser here; the endpoint builds a complete request to the OpenID Connect server and redirects the browser to it.

  • Sign In, the callback: the redirect-back endpoint that finishes the flow started by Login. ASP.NET Core names its route /signin-oidc, hence the name here: it receives the Authorization Code after successful authentication, exchanges it together with the PKCE code verifier for Access and Refresh tokens over a direct back-channel call, and issues the authentication cookie to the browser.

  • Logout: terminates the authentication session. The SPA POSTs to this endpoint, which deletes the local session and removes the authentication cookie; the POST is what keeps the endpoint out of reach of a cross-site navigation. Ending the provider's own session as well takes RP-initiated logout, which the sample does not wire up.

Now let's look at what the .NET platform provides out of the box for implementing this API. The Microsoft.AspNetCore.Authentication.OpenIdConnect NuGet package is a ready-made OpenID Connect client supported by Microsoft. It supports both Authorization Code Flow and PKCE, and it adds an endpoint at the relative path /signin-oidc that already implements the Sign In callback described above. Thus, we need to implement the remaining three endpoints only.

For a practical integration example, we will take a test OpenID Connect server based on the Abblix OIDC Server library. However, everything mentioned below applies to any other server, including publicly available servers from Facebook, Google, Apple and any others that comply with the OpenID Connect protocol specification.

To implement the SPA on the frontend side, we will use the React library, and on the backend side, we will use .NET WebAPI. This is one of the most common technology stacks at the time of writing this article.

The overall scheme of components and their interaction looks like this: Components and their interaction

To work with the examples from this article, you will also need to install the .NET SDK and Node.js. All examples in this article were developed and tested using .NET 8, 9, or 10, Node.js 22 (LTS) and React 19, which were current at the time of writing.

Creating a Client SPA on React with a Backend on .NET

To quickly create a client application, it's convenient to use a ready-made template. Up to .NET 7, the SDK offered a built-in template for a .NET WebAPI application and a React SPA. Unfortunately, this template was removed in .NET 8. That is why the Abblix team has created its own template, which includes a .NET WebApi backend, a frontend SPA based on the React library and TypeScript, built with Vite. This template is publicly available as part of the Abblix.Templates package, and you can install it by running the following command:

Bash
dotnet new install Abblix.Templates

Now we can use the template named abblix-react. Let's use it to create a new application called BffSample:

Bash
dotnet new abblix-react -n BffSample

This command creates an application consisting of a .NET WebApi backend and a React SPA client. The files related to the SPA are located in the BffSample\ClientApp folder.

After creating the project, the system will prompt you to run a command to install the dependencies:

Bash
cmd /c "cd ClientApp && npm install"

This action is necessary to install all the required dependencies for the client part of the application. Agree and run it by entering Y: the client app needs these packages installed.

Let's immediately change the port number on which the BffSample application runs locally to 5003. This action is not mandatory, but it will simplify further configuration of the OpenID Connect server. To do this, open the BffSample\Properties\launchSettings.json file, find the profile named https and change the value of the applicationUrl property to https://localhost:5003.

Next, add the NuGet package implementing the OpenID Connect client by navigating to the BffSample folder and executing the following command:

Bash
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

Set up two authentication schemes named Cookies and OpenIdConnect in the application, reading their settings from the application configuration. To do this, make changes to the BffSample\Program.cs file:

C#
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// ******************* START *******************
var configuration = builder.Configuration;

builder.Services
    .AddAuthorization()
    .AddAuthentication(options => configuration.Bind("Authentication", options))
    .AddCookie()
    .AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
// ******************** END ********************
var app = builder.Build();

And add the necessary settings for connecting to the OpenID Connect server in the BffSample\appsettings.json file:

JavaScript
{
  // ******************* START *******************
  "Authentication": {
      "DefaultScheme": "Cookies",
      "DefaultChallengeScheme": "OpenIdConnect"
  },
  "OpenIdConnect": {
      "SignInScheme": "Cookies",
      "SignOutScheme": "Cookies",
      "SaveTokens": true,
      "Scope": ["openid", "profile", "email"],
      "MapInboundClaims": false,
      "ResponseType": "code",
      "ResponseMode": "query",
      "UsePkce": true,
      "GetClaimsFromUserInfoEndpoint": false
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}

And in the BffSample\appsettings.Development.json file:

JavaScript
{
  // ******************* START *******************
  "OpenIdConnect": {
      "Authority": "https://localhost:5001",
      "ClientId": "bff_sample",
      "ClientSecret": "secret"
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}

Here is what each setting does:

  • Authentication section: The DefaultScheme property sets authentication by default using the Cookies scheme, and DefaultChallengeScheme delegates the execution of authentication to the OpenIdConnect scheme when the user cannot be authenticated by the default scheme. Thus, when the user is unknown to the application, the OpenID Connect server will be called for authentication, and after that, the authenticated user will receive an authentication cookie, and all further server calls will be authenticated with it, without contacting the OpenID Connect server.

  • OpenIdConnect section:

    • SignInScheme and SignOutScheme properties specify the Cookies scheme, which will be used to save the user's information after sign in.
    • The Authority property contains the base URL of the OpenID Connect server. ClientId and ClientSecret specify the client application's identifier and secret key, which are registered on the OpenID Connect server.
    • SaveTokens indicates the need to save the tokens received as a result of authentication from the OpenID Connect server.
    • Scope contains a list of scopes that the BffClient application requests access to. In this case, the standard scopes openid (user identifier), profile (user profile), and email (email) are requested. Note what is absent: without offline_access the provider issues no refresh token, so the access token here simply expires. Anything past a demo adds that scope and refreshes before expiry.
    • MapInboundClaims is responsible for transforming incoming claims from the OpenID Connect server into claims used in the application. A value of false means that claims will be saved in the authenticated user's session in the form in which they are received from the OpenID Connect server.
    • ResponseType with the value code indicates that the client will use the Authorization Code Flow.
    • ResponseMode specifies the transmission of the Authorization Code in the query string, which is the default method for Authorization Code Flow.
    • The UsePkce property indicates the need to use PKCE during authentication to prevent interception of the Authorization Code.
    • GetClaimsFromUserInfoEndpoint is false, so the profile claims come from the ID token rather than from a second call. The switch matters as soon as the client asks for an access token addressed to an API: that token names the API in its audience, and the UserInfo endpoint refuses a token minted for somebody else, as RFC 9068 requires of any resource server. Leave it on only while the access token is addressed to the provider itself.

The Cookies scheme issues the session cookie the whole pattern rests on, and the ASP.NET Core defaults for it are already sound: the cookie is HttpOnly, so scripts cannot read it, and SameSite=Lax, so browsers do not attach it to cross-site POST requests, which is exactly what shields endpoints like /bff/logout from classic CSRF without extra anti-forgery machinery. The sample keeps these defaults. For production, it is worth pinning down the rest explicitly:

C#
.AddCookie(options =>
{
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.ExpireTimeSpan = TimeSpan.FromHours(8);
    options.SlidingExpiration = true;
})

Tightening SameSite to Strict looks tempting but backfires here: the redirect back from the OpenID Connect server is a cross-site navigation, so with Strict the very first request after login arrives without the fresh cookie and the user can land in a login loop. Lax avoids this while still blocking cross-site POSTs. That is a consequence of ResponseMode: query. With form_post the code comes back on a cross-site POST that the framework's correlation cookies already handle, which is why the React SPA guide can use Strict.

Our application has no anonymous mode, so we load the React SPA only after successful authentication. Of course, if the SPA is loaded from an external source such as a Static Web Host, for example from Content Delivery Network (CDN) servers or a local development server started with the npm start command (for example, when running our example in debug mode), it will not be possible to check the authentication status before loading the SPA. But when our own .NET backend serves the SPA, the check becomes possible.

To do this, add the middleware responsible for authentication and authorization in the BffSample\Program.cs file:

C#
app.UseRouting();
// ******************* START *******************
app.UseAuthentication();
app.UseAuthorization();
// ******************** END ********************

At the end of the BffSample\Program.cs file, where the transition to loading the SPA is directly carried out, add the requirement for authorization .RequireAuthorization():

C#
app.MapFallbackToFile("index.html").RequireAuthorization();

Setting Up the OpenID Connect Server

As mentioned earlier, for the practical integration example, we will use a test OpenID Connect server based on the Abblix OIDC Server library. The base template for an application based on ASP.NET Core MVC with the Abblix OIDC Server library is also available in the Abblix.Templates package we installed earlier. Let's use this template to create a new application named OpenIDProviderApp:

Bash
dotnet new abblix-oidc-server -n OpenIDProviderApp

To configure the server, we need to register the BffClient application as a client on the OpenID Connect server and add a test user. To do this, add the following blocks to the OpenIDProviderApp\Program.cs file:

C#
var userInfoStorage = new TestUserStorage(
    // ******************* START *******************
    new UserInfo(
        Subject: "1234567890",
        Name: "John Doe",
        Email: "john.doe@example.com",
        Password: "Jd!2024$3cur3")
    // ******************** END ********************
);
builder.Services.AddSingleton(userInfoStorage);

// ...

// Register and configure Abblix OIDC Server
builder.Services.AddOidcServices(options =>
{
    // Configure OIDC Server options here:
    // ******************* START *******************
    // Client registrations are loaded from the Oidc section of appsettings.json
    builder.Configuration.Bind("Oidc", options);
    // ******************** END ********************
    // The following URL leads to Login action of the AuthController
    options.LoginUri = new Uri($"/Auth/Login", UriKind.Relative);

    // The following line generates a new key for token signing. Replace it if you want to use your own keys.
    options.SigningKeys = [JsonWebKeyFactory.CreateRsa(PublicKeyUsages.Signature)];
});

The client registration itself goes into the OpenIDProviderApp\appsettings.json file:

JavaScript
{
  "Oidc": {
    "Clients": [
      {
        "ClientId": "bff_sample",
        // The profile and email claims travel in the ID token, because this client reads them from
        // there rather than from the userinfo endpoint
        "ForceUserClaimsInIdentityToken": true,
        // SHA-512 hash of the test secret "secret" - the provider stores hashes, never plain secrets
        "ClientSecrets": [ { "Sha512Hash": "vSsar3708Jvp9Szi2NWZZ02Bqp1qRCFpbcTZPdBhnWgs5WtNZKnvCXdhztmeD2cmW192CF5bDufKRpayrW/isg==" } ],
        "TokenEndpointAuthMethod": "client_secret_post",
        "AllowedGrantTypes": [ "authorization_code" ],
        "PkceRequired": true,
        "RedirectUris": [ "https://localhost:5003/signin-oidc" ],
        "PostLogoutRedirectUris": [ "https://localhost:5003/signout-callback-oidc" ]
      }
    ]
  }
}

Here is what each part of this configuration does. We register a client with the identifier bff_sample and the secret key secret, stored as a Base64-encoded SHA-512 hash so the configuration never contains the secret itself. ForceUserClaimsInIdentityToken is the other half of the client-side switch above: the authorization code flow leaves user claims out of the ID token by default, on the assumption that the client will fetch them from userinfo, so a client that does not make that call has to ask for them here or receive none. TokenEndpointAuthMethod set to client_secret_post means the token acquisition sends the secret in the body of a POST message; this choice mirrors the client side, because Microsoft's OpenID Connect handler sends the client credentials in the body of the token request by default. AllowedGrantTypes specifies that the client is only allowed to use the Authorization Code Flow. PkceRequired mandates the use of PKCE during authentication. RedirectUris and PostLogoutRedirectUris contain lists of allowed URLs for redirection after authentication and session termination, respectively.

For any other OpenID Connect server, the settings will be similar, with differences only in how they are configured.

Implementing the basic BFF API

Earlier, we mentioned that using the Microsoft.AspNetCore.Authentication.OpenIdConnect package automatically adds the implementation of the Sign In endpoint to our sample application. Now we implement the remaining part of the BFF API. We will use an ASP.NET MVC controller for these additional endpoints. Let's start by adding a Controllers folder and a file BffController.cs in the BffSample project with the following code inside:

C#
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;

namespace BffSample.Controllers;

[ApiController]
[Route("[controller]")]
public class BffController : Controller
{
    public const string CorsPolicyName = "Bff";

    [HttpGet("check_session")]
    [EnableCors(CorsPolicyName)]
    public ActionResult<IDictionary<string, string>> CheckSession()
    {
        // return 401 Unauthorized to force SPA redirection to Login endpoint
        if (User.Identity?.IsAuthenticated != true)
            return Unauthorized();

        return User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value);
    }

    [HttpGet("login")]
    public ActionResult<IDictionary<string, string>> Login()
    {
        // Logic to initiate the authorization code flow
        return Challenge(new AuthenticationProperties { RedirectUri = Url.Content("~/") });
    }

    [HttpPost("logout")]
    public IActionResult Logout()
    {
        // Logic to handle logging out the user
        return SignOut();
    }
}

Here is what this class does:

  • The [Route("[controller]")] attribute sets the base route for all actions in the controller. In this case, the route will match the name of the controller, meaning all paths to our API methods will start with /bff/.

  • The constant CorsPolicyName = "Bff" defines the name of the CORS (Cross-Origin Resource Sharing) policy for use in method attributes. We will refer to it later.

  • The three methods CheckSession, Login, and Logout implement the necessary BFF functionality described above. They handle GET requests at /bff/check_session, /bff/login and POST requests at /bff/logout respectively.

  • The CheckSession method checks the user's authentication status. If the user is not authenticated, it returns a 401 Unauthorized code, which should force the SPA to redirect to the authentication endpoint. If authentication is successful, the method returns a set of claims and their values. This method also includes a CORS policy binding with the name CorsPolicyName since the call to this method can be cross-domain and contain cookies used for user authentication.

  • The Login method is called by the SPA if the previous CheckSession call returned 401 Unauthorized. It initiates the configured Challenge process, which will result in redirection to the OpenID Connect server, user authentication using Authorization Code Flow and PKCE, and issuing an authentication cookie. After this, control returns to the root of our application "~/", which will trigger the SPA to reload and start with an authenticated user.

  • The Logout method is also called by the SPA but terminates the current authentication session. It removes the authentication cookies issued by the server part of BffSample and also calls the End Session endpoint on the OpenID Connect server side.

Configuring CORS for BFF

As mentioned above, the CheckSession method is intended for asynchronous calls from the SPA (usually using the Fetch API). This method works only if the browser can send authentication cookies with the request. If the SPA is loaded from a separate Static Web Host, such as a CDN or a dev server running on a separate port, this call becomes cross-domain. This makes configuring a CORS policy necessary, without which the SPA will not be able to call this method.

We already indicated in the controller code in the Controllers\BffController.cs file that the CORS policy named CorsPolicyName = "Bff" is to be used. Now we configure this policy. Let's return to the BffSample/Program.cs file and add the following code blocks:

C#
// ******************* START *******************
using BffSample.Controllers;
// ******************** END ********************
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

// ...

builder.Services
    .AddAuthorization()
    .AddAuthentication(options => configuration.Bind("Authentication", options))
    .AddCookie()
    .AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
// ******************* START *******************
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();
        }));
// ******************** END ********************
var app = builder.Build();

This code allows the CORS policy methods to be called from SPAs loaded from sources specified in the configuration as an array of strings CorsSettings:AllowedOrigins, using the GET method and allows cookies to be sent in this call. Also, add the call to app.UseCors(...) right before app.UseAuthentication():

C#
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// ******************* START *******************
app.UseCors(BffController.CorsPolicyName);
// ******************** END ********************
app.UseAuthentication();
app.UseAuthorization();

To ensure the CORS policy works correctly, add the corresponding setting to the BffSample\appsettings.Development.json configuration file:

JavaScript
{
  // ******************* START *******************
  "CorsSettings": {
    "AllowedOrigins": [ "https://localhost:3000" ]
  },
  // ******************** END ********************
 "OpenIdConnect": {
   "Authority": "https://localhost:5001",
   "ClientId": "bff_sample"
  }
}

In our example, the address https://localhost:3000 is the address where the dev server with the React SPA is launched using the npm run dev command. You can find out this address in your case by opening the BffSample.csproj file and finding the value of the SpaProxyServerUrl parameter. If your SPA is loaded from a different address and port than the one providing the BFF API, you must add that address to the CORS policy configuration.

A production caveat: this setup works effortlessly on localhost because different ports still count as the same site, so the session cookie flows freely. Browsers are far less forgiving across sites: Safari blocks third-party cookies outright and Firefox isolates them per site, so a deployment where the SPA and the BFF live on two unrelated domains would force SameSite=None cookies and simply break for a large share of users. For production, host the SPA and the BFF within one site (for example, app.example.com and bff.example.com): requests between subdomains still need the CORS policy above, but the session cookie remains first-party and keeps its SameSite=Lax protection.

Implementing Authentication via BFF in a React Application

We have implemented the BFF API on the server side. Now the React SPA needs the code that calls this API. Let's start by navigating to the BffSample\ClientApp\src\ folder, creating a components folder, and adding a Bff.tsx file with the following content:

TSX
import { createContext, useCallback, useContext, useEffect, useState, ReactNode, FC } from 'react';

type UserClaims = Record<string, unknown>;

// Define the shape of the BFF context
interface BffContextProps {
    user: UserClaims | null;
    fetchBff: (endpoint: string, options?: RequestInit) => Promise<Response>;
    checkSession: () => Promise<void>;
    login: () => void;
    logout: () => Promise<void>;
}

// Creating a context for BFF to share state and functions across the application
const BffContext = createContext<BffContextProps>({
    user: null,
    fetchBff: async () => new Response(),
    checkSession: async () => {},
    login: () => {},
    logout: async () => {}
});

interface BffProviderProps {
    baseUrl: string;
    children: ReactNode;
}

export const BffProvider: FC<BffProviderProps> = ({ baseUrl, children }) => {
    const [user, setUser] = useState<UserClaims | null>(null);

    // Normalize the base URL by removing a trailing slash to avoid inconsistent URLs
    const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;

    const fetchBff = useCallback(
        async (endpoint: string, options: RequestInit = {}): Promise<Response> => {
            try {
                // The fetch function includes credentials to handle cookies, which are necessary for authentication
                return await fetch(`${normalizedBaseUrl}/${endpoint}`, {
                    credentials: 'include',
                    ...options
                });
            } catch (error) {
                console.error(`Error during ${endpoint} call:`, error);
                throw error;
            }
        },
        [normalizedBaseUrl]
    );

    // The login function redirects to the login page when user needs to authenticate
    const login = useCallback((): void => {
        window.location.replace(`${normalizedBaseUrl}/login`);
    }, [normalizedBaseUrl]);

    // The checkSession function is responsible for verifying the user session on initial render
    const checkSession = useCallback(async (): Promise<void> => {
        const response = await fetchBff('check_session');

        if (response.ok) {
            // If the session is valid, update the user state with the received claims data
            setUser(await response.json());
        } else if (response.status === 401) {
            // If the user is not authenticated, redirect him to the login page
            login();
        } else {
            console.error('Unexpected response from checking session:', response);
        }
    }, [fetchBff, login]);

    // Function to log out the user
    const logout = useCallback(async (): Promise<void> => {
        const response = await fetchBff('logout', { method: 'POST' });

        if (response.ok) {
            // Redirect to the home page after successful logout
            window.location.replace('/');
        } else {
            console.error('Logout failed:', response);
        }
    }, [fetchBff]);

    // useEffect is used to run the checkSession function once the component mounts.
    // The useCallback wrappers above keep the function identities stable between renders,
    // so this effect does not re-run on every render
    useEffect(() => { checkSession(); }, [checkSession]);

    return (
        // Providing the BFF context with relevant values and functions to be used across the application
        <BffContext.Provider value={{ user, fetchBff, checkSession, login, logout }}>
            {children}
        </BffContext.Provider>
    );
};

// Custom hook to use the BFF context easily in other components
export const useBff = (): BffContextProps => useContext(BffContext);

This file exports:

  • The BffProvider component, which creates a context for BFF and provides functions and state related to authentication and session management for the entire application.

  • The custom hook useBff(), which returns an object with the current user state and functions to work with BFF: checkSession, login, and logout.

Next, create a UserClaims component, which will display the current user's claims upon successful authentication. Create a UserClaims.tsx file in the BffSample\ClientApp\src\components folder with the following content:

TSX
import React from 'react';
import { useBff } from './Bff';

export const UserClaims: React.FC = () => {
    const { user } = useBff();

    if (!user)
        return <div>Checking user session...</div>;

    return (
        <>
            <h2>User Claims</h2>
            <p>This component displays claims received from the OpenID Connect server.</p>
            {Object.entries(user).map(([claim, value]) => (
                <div key={claim}>
                    <strong>{claim}</strong>: {String(value)}
                </div>
            ))}
        </>
    );
};

This code checks for an authenticated user using the useBff() hook and displays the user's claims as a list if the user is authenticated. If the user data is not yet available, it displays the text Checking user session....

Now, let's move to the BffSample\ClientApp\src\App.tsx file. Replace its content with the necessary code. Import BffProvider from components/Bff.tsx and UserClaims from components/UserClaims.tsx, and insert the main component code:

TSX
import { BffProvider, useBff } from './components/Bff';
import { UserClaims } from './components/UserClaims';

const LogoutButton: React.FC = () => {
    const { logout } = useBff();
    return (
        <button className="logout-button" onClick={logout}>
            Logout
        </button>
    );
};

const App: React.FC = () => (
    <BffProvider baseUrl="https://localhost:5003/bff">
        <div className="card">
            <UserClaims/>
        </div>
        <div className="card">
            <LogoutButton />
        </div>
    </BffProvider>
);

export default App;

Here the baseUrl parameter specifies the base URL of our BFF API https://localhost:5003/bff. The hard-coded URL is intentional, to keep the sample simple. In a real application, you should provide this setting dynamically rather than hard-coding it. There are various ways to achieve this, but discussing them is beyond the scope of this article.

The Logout button allows the user to log out. It calls the logout function available through the useBff hook and redirects the user's browser to the /bff/logout endpoint, which terminates the user's session on the server side.

At this stage, you can now run the BffSample application together with the OpenIDProviderApp and test its functionality. You can use the command dotnet run -lp https in each project or your favorite IDE to start them. Both applications must be running simultaneously.

After this, open your browser and navigate to https://localhost:5003. If everything is set up correctly, the SPA will load and call /bff/check_session. The /check_session endpoint will return a 401 response, prompting the SPA to redirect the browser to /bff/login, which will then initiate authentication on the server via the OpenID Connect Authorization Code Flow using PKCE. You can observe this sequence of requests by opening the Development Console in your browser and going to the Network tab. After successfully entering the user credentials (john.doe@example.com, Jd!2024$3cur3), control will return to the SPA, and you will see the authenticated user's claims in the browser:

Code
sub: 1234567890
sid: V14fb1VQbAFG6JXTYQp3D3Vpa8klMLcK34RpfOvRyxQ
auth_time: 1717852776
name: John Doe
email: john.doe@example.com

Clicking the Logout button will redirect the browser to /bff/logout, which logs the user out; you will then see the login page again with a prompt to enter your username and password.

If you encounter any errors, you can compare your code with our GitHub repository Abblix/Oidc.Server.GettingStarted, which contains this and other examples ready to run.

Resolving HTTPS Certificate Trust Issues

When locally testing web applications configured to run over HTTPS, you may encounter browser warnings that the SSL certificate is not trusted. This issue arises because the development certificates used by ASP.NET Core are not issued by a recognized Certification Authority (CA) but are self-signed or not present in the system at all. These warnings can be eliminated by executing the following command once:

Bash
dotnet dev-certs https --trust

This command generates a self-signed certificate for localhost and installs it in your system so that it trusts this certificate. The certificate will be used by ASP.NET Core to run web applications locally. After running this command, restart your browser for the changes to take effect.

A note for Chrome users: even after installing the development certificate as trusted, some versions of Chrome may still restrict access to localhost sites for security reasons. If you encounter an error indicating that your connection is not secure and access to localhost is blocked by Chrome, you can bypass this as follows:

  • Click anywhere on the error page and type thisisunsafe or badidea, depending on your Chrome version. These keystroke sequences act as bypass commands in Chrome, allowing you to proceed to the localhost site.

It's important to use these bypass methods only in development scenarios, as they can pose real security risks.

Calling Third-Party APIs via BFF

We have successfully implemented authentication in our BffSample application. Now let's call a third-party API that requires an access token.

Imagine we have a separate service that provides the necessary data, such as weather forecasts, and access to it is granted only with an access token. The role of the server part of BffSample will be to act as a reverse proxy, i.e., accept and authenticate the request for data from the SPA, add the access token to it, forward this request to the weather service, and then return the response from the service back to the SPA.

Creating the ApiSample Service

Before demonstrating the remote API call through the BFF, we need to create an application that will serve as this API in our example.

BFF with YARP

To create the application, we will use a template provided by .NET. Navigate to the folder containing the OpenIDProviderApp and BffSample projects, and run the following command to create the ApiSample application:

Bash
dotnet new webapi -n ApiSample

This ASP.NET Core Minimal API application serves a single endpoint with path /weatherforecast that provides weather data in JSON format.

First of all, change the randomly assigned port number that the ApiSample application uses locally to a fixed port, 5004. As mentioned earlier, this step is not mandatory but it simplifies our setup. To do this, open the ApiSample\Properties\launchSettings.json file, find the profile named https and change the value of the applicationUrl property to https://localhost:5004.

Now let's make the weather API accessible only with an access token. Navigate to the ApiSample project folder and add the NuGet package for JWT Bearer token authentication:

Bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Configure the authentication scheme and authorization policy named WeatherApi in the ApiSample\Program.cs file:

C#
// ******************* START *******************
using System.Security.Claims;
// ******************** END ********************
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ******************* START *******************
var configuration = builder.Configuration;

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);
        });
    }));
// ******************** END ********************
var app = builder.Build();

This code block sets up authentication by reading the configuration from the application settings, includes authorization using JWT (JSON Web Tokens) and configures an authorization policy named WeatherApi. The WeatherApi authorization policy sets the following requirements:

  • policy.RequireAuthenticatedUser(): Ensures that only authenticated users can access protected resources.

  • policy.RequireAssertion(context => ...): The user must have a scope claim that includes the value weather. Since the scope claim can contain multiple values separated by spaces according to RFC 8693, the actual scope value is split into individual parts, and the resulting array is checked for the required weather value.

Together, these conditions ensure that only authenticated users with an access token authorized for the weather scope can call the endpoint protected by this policy.

We need to apply this policy to the /weatherforecast endpoint. Add the call to RequireAuthorization() as shown below:

C#
app.MapGet("/weatherforecast", () =>
{

// ...

})
.WithName("GetWeatherForecast")
// ******************* START *******************
.WithOpenApi()
.RequireAuthorization(policyName);
// ******************** END ********************

Add the necessary configuration settings for the authentication scheme to the appsettings.Development.json file of the ApiSample application:

JavaScript
{
  // ******************* START *******************
  "JwtBearerAuthentication": {
    "Authority": "https://localhost:5001",
    "MapInboundClaims": false,
    "TokenValidationParameters": {
      "ValidTypes": [ "at+jwt" ],
      "ValidAudience": "https://localhost:5004",
      "ValidIssuer": "https://localhost:5001"
    }
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}

Consider each setting:

  • Authority: This is the URL pointing to the OpenID Connect authorization server that issues JWT tokens. The authentication provider configured in the ApiSample application will use this URL to obtain the information needed to verify tokens, such as signing keys.

  • MapInboundClaims: This setting controls how incoming claims from the JWT token are mapped to internal claims in ASP.NET Core. It is set to false, meaning claims will use their original names from the JWT.

  • TokenValidationParameters:

    • ValidTypes: Set to at+jwt, which according to RFC 9068 2.1 indicates an Access Token in JWT format.
    • ValidAudience: Specifies that the application will accept tokens issued for the client https://localhost:5004.
    • ValidIssuer: Specifies that the application will accept tokens issued by the server https://localhost:5001.

Additional Configuration of OpenIDProviderApp

The combination of the authentication service OpenIDProviderApp and the client application BffSample works well for providing user authentication. However, to enable calls to a remote API, we need to register the ApiSample application as a resource with OpenIDProviderApp. In our example, we use the Abblix OIDC Server, which supports RFC 8707: Resource Indicators for OAuth 2.0. Therefore, we will register the ApiSample application as a resource with the scope weather. If you are using another OpenID Connect server that does not support Resource Indicators, it is still recommended to register a unique scope for this remote API (such as weather in our example).

Add the following code to the file OpenIDProviderApp\Program.cs:

C#
// Register and configure Abblix OIDC Server
builder.Services.AddOidcServices(options =>
{
    // Client registrations are loaded from the Oidc section of appsettings.json
    builder.Configuration.Bind("Oidc", options);

    // ******************* START *******************
    options.Resources =
    [
        new(new Uri("https://localhost:5004", UriKind.Absolute), new ScopeDefinition("weather")),
    ];
    // ******************** END ********************

In this example, we register the ApiSample application, specifying its base address https://localhost:5004 as a resource and defining a specific scope named weather. In real-world applications, especially those with complex APIs consisting of many endpoints, define separate scopes for each endpoint or group of related endpoints. For instance, you can create distinct scopes for different operations, application modules, or user access levels.

Extending BffSample to Proxy Requests to a Remote API

The client application BffSample now needs to do more than just request an access token for ApiSample. It must also proxy requests from the SPA to the remote API, adding the access token before forwarding them. In essence, BffSample needs to function as a reverse proxy server.

Instead of manually implementing request proxying in our client application, we will use YARP (Yet Another Reverse Proxy), a ready-made product developed by Microsoft. YARP is a reverse proxy server written in .NET and available as a NuGet package.

To use YARP in the BffSample application, first add the NuGet package:

Bash
dotnet add package Yarp.ReverseProxy

Then add the following namespaces at the beginning of the file BffSample\Program.cs:

C#
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.Net.Http.Headers;
using Yarp.ReverseProxy.Transforms;

Before the call var app = builder.Build();, add the code:

C#
builder.Services.AddHttpForwarder();

And between the calls to app.MapControllerRoute() and app.MapFallbackToFile():

C#
app.MapForwarder(
    "/bff/{**catch-all}",
    configuration.GetValue<string>("OpenIdConnect:Resource") ?? throw new InvalidOperationException("Unable to get OpenIdConnect:Resource from current configuration"),
    builderContext =>
    {
        // Remove the "/bff" prefix from the request path
        builderContext.AddPathRemovePrefix("/bff");

        builderContext.AddRequestTransform(async transformContext =>
        {
            // Get the access token received earlier during the authentication process
            var accessToken = await transformContext.HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
            
            // Append a header with the access token to the proxy request
            transformContext.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        });
    }).RequireAuthorization();

Here is what this code does:

  • builder.Services.AddHttpForwarder() registers the services YARP needs in the DI container.

  • app.MapForwarder sets up request forwarding to another server or endpoint.

  • "/bff/{**catch-all}" is the path pattern that the reverse proxy will respond to. All requests starting with /bff/ will be processed by YARP. {**catch-all} is used to capture all remaining parts of the URL after /bff/.

  • configuration.GetValue<string>("OpenIdConnect:Resource") uses the application's configuration to get the value from the OpenIdConnect:Resource section. This value specifies the resource address to which the requests will be forwarded. In our example, this value will be https://localhost:5004 - the base URL where the ApiSample application operates.

  • builderContext => ... adds the necessary transformations that YARP will perform on each incoming request from the SPA. In our case, there will be two such transformations:

    • builderContext.AddPathRemovePrefix("/bff") removes the /bff prefix from the original request path.

    • builderContext.AddRequestTransform(async transformContext => ...) adds an Authorization HTTP header to the request, containing the access token that was previously obtained during authentication. Thus, requests from the SPA to the remote API will be authenticated using the access token, even though the SPA itself does not have access to this token.

  • .RequireAuthorization() specifies that authorization is required for all forwarded requests. Only authorized users will be able to access the route /bff/{**catch-all}.

A natural question: the BffController also lives under /bff/. Will its endpoints be swallowed by the forwarder? No. In ASP.NET Core endpoint routing, a catch-all template has the lowest precedence, so the specific controller routes (/bff/check_session, /bff/login, /bff/logout) always win, and only unmatched /bff/... paths fall through to the proxy.

To request an access token for the resource https://localhost:5004 during authentication, add the Resource parameter with the value https://localhost:5004 to the OpenIdConnect configuration in the BffSample/appsettings.Development.json file:

JavaScript
  "OpenIdConnect": {
    // ******************* START *******************
    "Resource": "https://localhost:5004",
    // ******************** END ********************
    "Authority": "https://localhost:5001",
    "ClientId": "bff_sample",

Also, add another value weather to the scope array in the BffSample/appsettings.json file:

JavaScript
{
  "OpenIdConnect": {

    // ...

    // ******************* START *******************
    "Scope": ["openid", "profile", "email", "weather"],
    // ******************** END ********************

    // ...

  }
}
NOTES

In a real project, it is necessary to monitor the expiration of the access token. When the token is about to expire, you should either request a new one in advance using a refresh token from the authentication service or handle an access denial error from the remote API by obtaining a new token and retrying the original request. For the sake of brevity, we have deliberately omitted this aspect in this article.

Requesting the Weather API via BFF in the SPA Application

The backend is ready now. We have the ApiSample application, which implements an API with token-based authorization, and the BffSample application, which includes an embedded reverse proxy server to provide secure access to this API. The final step is to add the functionality to request this API and display the returned data in the React SPA.

Add the file WeatherForecast.tsx in BffSample\ClientApp\src\components with the following content:

TSX
import React, { useEffect, useState } from 'react';
import { useBff } from './Bff';

interface Forecast {
    date: string;
    temperatureC: number;
    temperatureF: number;
    summary: string;
}

interface State {
    forecasts: Forecast[];
    loading: boolean;
}

export const WeatherForecast: React.FC = () => {
    const { fetchBff } = useBff();
    const [state, setState] = useState<State>({ forecasts: [], loading: true });
    const { forecasts, loading } = state;

    useEffect(() => {
        fetchBff('weatherforecast')
            .then(response => response.json())
            .then(data => setState({ forecasts: data, loading: false }));
    }, [fetchBff]);

    const contents = loading
        ? <p><em>Loading...</em></p>
        : (
            <table className="table table-striped" aria-labelledby="tableLabel">
                <thead>
                <tr>
                    <th>Date</th>
                    <th>Temp. (C)</th>
                    <th>Temp. (F)</th>
                    <th>Summary</th>
                </tr>
                </thead>
                <tbody>
                {forecasts.map((forecast, index) => (
                    <tr key={index}>
                        <td>{forecast.date}</td>
                        <td align="center">{forecast.temperatureC}</td>
                        <td align="center">{forecast.temperatureF}</td>
                        <td>{forecast.summary}</td>
                    </tr>
                ))}
                </tbody>
            </table>
        );

    return (
        <div>
            <h2 id="tableLabel">Weather forecast</h2>
            <p>This component demonstrates fetching data from the server.</p>
            {contents}
        </div>
    );
};

A quick walk through this code:

  • The Forecast interface defines the structure of the weather forecast data, which includes the date, temperature in Celsius and Fahrenheit, and a summary of the weather. The State interface describes the structure of the component's state, consisting of an array of weather forecasts and a loading flag.

  • The WeatherForecast component retrieves the fetchBff function from the useBff hook and uses it to fetch weather data from the server. The component's state is managed using the useState hook, initializing with an empty array of forecasts and a loading flag set to true.

  • The useEffect hook triggers the fetchBff function when the component mounts, fetching weather forecast data from the server at the /bff/weatherforecast endpoint. Once the server's response is received and converted to JSON, the data is stored in the component's state (via setState), and the loading flag is updated to false.

  • Depending on the value of the loading flag, the component either displays a "Loading..." message or renders a table with the weather forecast data.

Now, add the WeatherForecast component to BffSample\ClientApp\src\App.tsx:

TSX
// ******************* START *******************
import { WeatherForecast } from "./components/WeatherForecast";
// ******************** END ********************

// ...

    <div className="card">
        <UserClaims/>
    </div>
    // ******************* START *******************
    <div className="card">
        <WeatherForecast/>
    </div>
    // ******************** END ********************
    <div className="card">
        <LogoutButton />
    </div>

Running and Testing

If everything has been done right, you can now start all three of our projects. Use the console command dotnet run -lp https for each application to run them with HTTPS.

After launching all three applications, open the BffSample application in your browser and authenticate using the credentials john.doe@example.com and Jd!2024$3cur3. Upon successful authentication, you should see the list of claims received from the authentication server, as seen previously. Below this, you will also see the weather forecast.

The weather forecast is provided by the separate application ApiSample, which uses an access token issued by the authentication service OpenIDProviderApp. Seeing the weather forecast in the BffSample application window indicates that our SPA successfully called the backend of BffSample, which then proxied the call to ApiSample by adding the access token. ApiSample authenticated the call and responded with JSON containing the weather forecast.

The Complete Solution is Available on GitHub

Clone the repository Abblix/Oidc.Server.GettingStarted to get the fully implemented projects described in this article. Use it to troubleshoot your code or as a starting point for your own projects.

Conclusion

The move from the Implicit Flow to Authorization Code with PKCE fixed how tokens are obtained; it could not fix where they live. As long as tokens stay in the browser, every one of them is within reach of any script that runs in your origin, and neither rotation nor proof-of-possession changes that. The BFF pattern does: the backend becomes the OAuth client, tokens never leave it, and the browser holds nothing worth stealing.

The implementation turned out to be mostly assembly rather than construction. Microsoft.AspNetCore.Authentication.OpenIdConnect covers the protocol, YARP covers the proxying, and the BFF API proper fits in one small controller. Clone Abblix/Oidc.Server.GettingStarted, run the three projects, and then swap the test OpenID Connect server for your own. The pattern stays the same.