Skip to main content

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 examines why BFF has become the recommended way to secure SPAs and then builds a working implementation on .NET and React, step by step.

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 detailed article on this topic that 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?

JavaScript Code Vulnerabilities

Modern SPAs built on React, Vue or Angular use a vast number of libraries and dependencies. You can see them in the node_modules folder, and the number of such dependencies can be in the hundreds or even thousands. Each of these libraries may contain vulnerabilities of varying degrees of criticality, and SPA developers do not have the ability to thoroughly check the code of all the dependencies used. Often developers do not even track the full list of dependencies, as they are transitively dependent on each other. Even if you write your own code to the highest standard, you cannot be sure the finished application is free of vulnerabilities.

Malicious JavaScript code, which can be injected into an application in various ways, through attacks such as Cross-Site Scripting (XSS) or through the compromise of third-party libraries, gains the same privileges and level of access to data as the legitimate application code. This allows malicious code to steal data from the current page, interact with the application interface, send requests to the backend, steal data from local storage (localStorage, IndexedDB), and even initiate authentication sessions itself, obtaining its own access tokens using the same Authorization Code and PKCE flow.

Spectre-Class Attacks

Spectre demonstrated that even the hardware under the browser cannot be fully trusted: speculative-execution side channels let code read memory it was never supposed to see, and a long-lived SPA gives malicious JavaScript plenty of time to try. Browsers have since deployed serious mitigations (site isolation, coarser timers, cross-origin isolation) which made practical exploitation from JavaScript much harder. For our topic the lasting lesson is defense in depth: a secret that never reaches the browser cannot be read from its memory, whatever the next side-channel vulnerability turns out to be.

Why Refresh Token Rotation Alone Doesn't Protect SPAs

A reader familiar with the current OAuth 2.0 guidance might object at this point: surely refresh token rotation handles the refresh-token theft problem? The OAuth 2.0 for Browser-Based Applications draft explicitly recommends it, and the mechanism sounds reassuring. Each refresh token can only be used once. If the authorization server sees the same refresh token presented twice, it treats that as a theft indicator, revokes the entire token family, and forces re-authentication.

The problem is what happens when the attacker is running inside the compromised frontend, not outside it.

Philippe De Ryck makes this point directly in his talk "Breaking and Securing OAuth 2.0 in Frontends" (YOW! 2025): rotation defends against an attacker who stole a token and is now trying to use it from somewhere else. But malicious JavaScript in the same origin as the legitimate application can simply observe every refresh exchange and ensure the attacker always holds the latest valid refresh token. The legitimate application never sees a stale token to reuse, alarm bells never ring, and the attacker maintains access indefinitely: limited only by the refresh token's maximum lifetime, which is usually measured in days or weeks.

In other words, refresh token rotation assumes the attacker is passive and outside the browser. An attacker with an XSS foothold inside the browser is neither. Rotation stays useful (it closes off attacks from stolen-and-exfiltrated tokens) but it does not address the core problem that tokens stored in the browser are always one XSS away from full compromise.

Doesn't DPoP Solve This?

Another modern counterargument: DPoP binds tokens to a cryptographic key, so a stolen token cannot be used without it. In a browser, the key pair can even be generated as non-extractable through the Web Crypto API, so malicious JavaScript cannot copy the key itself.

The catch is the same as with refresh token rotation: the attacker does not need to extract anything. Code running inside the compromised frontend can call the same Web Crypto API, sign valid DPoP proofs with that non-extractable key, and use the tokens from within the browser, or simply run its own authentication flow. DPoP is genuinely valuable against token exfiltration and replay from another machine, and the browser-based apps draft recommends it as a hardening measure. What it does not change is the trust boundary: whatever the legitimate frontend can do, injected code in the same origin can do too. BFF moves the tokens (and with them that boundary) out of the browser entirely.

What to Do?

Modern SPAs, dependent on a large number of third-party JavaScript libraries and running in the browser environment on user devices, operate in a software and hardware environment that developers cannot fully control. Therefore, we should consider such applications inherently vulnerable.

Given these threats, more and more experts recommend not storing tokens in the browser at all, designing the application so that access and refresh tokens are obtained and processed only by the server side of the application, and they are never passed to the browser side. In the context of an SPA with a backend, this can be achieved using the Backend-For-Frontend (BFF) architectural pattern.

The interaction diagram 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

Access and refresh tokens are stored on the server side and are never passed to the browser, preventing their theft due to vulnerabilities. Session and token management are handled on the server, which enforces session lifetime and token audience centrally. The client application interacts with the server through the BFF, which simplifies the application logic.

Why Not Just Store Encrypted Tokens in HttpOnly Cookies?

A common objection to the BFF pattern is: why not encrypt access tokens and store them directly in HttpOnly cookies? This avoids server-side session state while keeping tokens out of JavaScript reach.

While this approach does protect tokens from direct JavaScript access, it misses a critical distinction: scope of authority.

An encrypted access token in a cookie (even if the browser can't read it) is still sent with every request. If that token grants access to multiple APIs (which is common in microservice architectures), a compromised frontend can proxy requests to any of those APIs through the browser. The token's full scope travels with every request, whether legitimate or malicious.

With BFF, the browser holds only a session cookie scoped to one specific backend. The backend decides which APIs to call and which tokens to use. A compromised frontend can only interact with the BFF's own API surface: it cannot reach other services directly, because the access tokens never leave the server.

"But XSS Can Still Make Authenticated Requests Through Cookies"

This is true, and it's important to acknowledge. The BFF pattern does not eliminate XSS as a threat. If malicious JavaScript runs in the browser, it can make requests to the BFF backend using the session cookie, just as it could with any cookie-based authentication.

What BFF prevents is token theft and scope escalation:

  • Without BFF: Malicious code can steal access tokens from memory or storage, exfiltrate them to an attacker's server, and use them independently: from any location, against any API the token grants access to, even after the user closes the browser.
  • With BFF: Malicious code can only make requests through the user's browser session to the BFF backend. It cannot extract tokens, cannot use them from another machine, and cannot access APIs that the BFF doesn't expose. The attack surface is limited to the BFF's own API, which is under the developer's complete control.

The BFF backend can also enforce request-level protections that aren't possible on the frontend: rate limiting, anomaly detection, anti-forgery tokens, and request validation, all server-side, where malicious code cannot tamper with them.

What About Anti-Forgery Tokens?

Anti-forgery (CSRF) tokens protect against cross-site request forgery: they ensure that requests originate from your own frontend, not from a malicious third-party page. However, they do not help when malicious code is already running inside your application (XSS). In an XSS scenario, the attacker's code executes in the same origin as your legitimate code, so it can read anti-forgery tokens from the DOM and include them in requests. Anti-forgery tokens solve a different problem (CSRF), not the problem BFF addresses (token exposure in a compromised frontend).

"Aren't We Changing the Fundamental Principle of OAuth?"

A fair objection. In traditional OAuth 2.0, the access token is issued to the client: like an ID card that the client presents to various services. In BFF, we shift who the "client" is: the backend becomes the OAuth client, and the browser becomes a session-authenticated consumer of the backend's API.

This is an intentional design choice, not a workaround. The OAuth 2.0 for Browser-Based Applications specification explicitly recommends this approach, recognizing that browsers are inherently hostile environments for storing security credentials. The principle of OAuth isn't violated. It's applied correctly by treating the backend as the confidential client it is, and the browser as an untrusted presentation layer.

CORS Is Necessary But Not Sufficient

Properly configured CORS prevents malicious code from sending stolen data to third-party servers via fetch or XMLHttpRequest. However, CORS can be bypassed through techniques like hidden form submissions, navigator.sendBeacon(), or constructing image/script tags with attacker-controlled URLs that encode stolen data in query parameters. CORS is an important layer of defense, but it should not be the only one.

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 redirect-back callback that finishes the flow started by Login: 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 redirects the browser to this URL, which forms a request to the End Session endpoint on the OpenID Connect server, deletes the local session, and removes the authentication cookie.

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:

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:

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:

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:

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:

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:

{
// ******************* 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": true
},
// ******************** END ********************
"Logging": {
"LogLevel": {
"Default": "Information",

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

{
// ******************* 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.
    • 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.
    • The GetClaimsFromUserInfoEndpoint property indicates that user profile data should be obtained from the UserInfo endpoint.

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:

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

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:

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

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:

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:

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:

{
"Oidc": {
"Clients": [
{
"ClientId": "bff_sample",
// 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. 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:

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 ensures that the user is still not authenticated and 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:

// ******************* 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():

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:

{
// ******************* 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:

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:

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:

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:

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:

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:

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:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

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

// ******************* 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:

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:

{
// ******************* 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:

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

dotnet add package Yarp.ReverseProxy

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

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:

builder.Services.AddHttpForwarder();

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

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:

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

{
"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:

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:

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

// ...

<div className="card">
<UserClaims/>
</div>
// ******************* START *******************
<div className="card">
<WeatherForecast/>
</div>
// ******************** END ********************
<div className="card">
<button className="logout-button" onClick={logout}>
Logout
</button>
</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.