Mert Özen Further With Every Line
Security Authentication and Authorization — #14

The BFF Pattern for SPAs: The Architecture RFC 10017 Recommends with Angular, ASP.NET Core and YARP

Mert Özen Nov 8, 2026 12 min 4 views
The BFF Pattern for SPAs: The Architecture RFC 10017 Recommends with Angular, ASP.NET Core and YARP

There is no safe place for tokens in the browser; the option RFC 10017 rates most secure is a BFF that keeps tokens on the server. In ASP.NET Core 10 we set up OIDC with a cookie session, attach tokens via YARP on the way to the API, renew tokens inside the BFF, defend against CSRF with a custom header and build an XHR-friendly logout; curl output shows Angular never sees a token, and we weigh the costs.

Version note: The code in this post was built and run with .NET 10, Yarp.ReverseProxy 2.3.0, Microsoft.AspNetCore.Authentication.OpenIdConnect 10.0.14, Angular 22.2 and Keycloak 26.8.2.

Guess First

Bookstore's Angular app gets a token from Keycloak and calls the API (Application Programming Interface). Where should it keep the token: localStorage, sessionStorage, or memory? Assume the page has an XSS (Cross-Site Scripting) hole; which one stops the attacker? The answer surprises most people: none of them. Even if an attacker who can run code on the page cannot steal the token, they can ask the authorization server for a new token from inside the same page. In every architecture that keeps tokens in the browser, that door is open.

RFC (Request for Comments) 10017, OAuth 2.0 for Browser-Based Applications, was published in August 2026 as BCP (Best Current Practice) 212, and it answers this question at the architectural level. In this post we build the most secure architecture it recommends, the BFF (Backend for Frontend) pattern, with ASP.NET Core 10 and YARP (Yet Another Reverse Proxy), and show with curl output that the Angular side never sees a token. The threat model of storing tokens in the browser is the subject of post 05 of the upcoming "Frontend Security" series; here we focus on the architecture.

RFC 10017: Three Architectures, in Order of Security

RFC 10017 defines three patterns for browser-based apps and ranks them by security:

PatternWho is the OAuth clientWhere the tokens liveAn attacker running code on the page
BFFThe BFF on the server (confidential)Only on the serverCannot steal tokens; can send requests through the BFF while the session lasts
Token-mediating backendA backend on the server (confidential)Refresh token on the server, access token in the browserCan steal the access token and request new ones
OAuth client in the browserThe Angular app (public)All in the browserCan steal tokens and obtain new ones

The document also lists discouraged patterns: the implicit grant, the password grant, running the OAuth flow in a Service Worker (an attacker can bypass the Service Worker and restart the flow in a fresh context), and using OAuth needlessly in single-domain apps. The last one is interesting: if the frontend and API live in the same place, an ordinary cookie session as in post 02 is often enough and simpler.

The reason BFF tops the table: you can't fully prevent XSS, but you can limit its damage. With a BFF the attacker can send requests only while the user's session is open, and only to addresses the BFF allows; there is no token to carry away.

The Parts of a BFF

14-bff-architecture-en.png

There are three processes in the lab. Keycloak is at http://localhost:19200. The Bookstore API from post 13 is at http://localhost:19213, unchanged; it is protected with JWT (JSON Web Token) Bearer and knows nothing about the BFF. The BFF is at https://localhost:19214: it serves Angular's built files, it is the confidential client bookstore-bff that talks OIDC (OpenID Connect) with Keycloak, the only thing between it and the user is a cookie, and it forwards /api/* requests to the API with a token attached. Angular and the BFF share an origin; no CORS (Cross-Origin Resource Sharing), no preflight. The BFF's certificate was generated with openssl in the work folder; a __Host- cookie does not work without HTTPS (HTTP Secure).

builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        // Challenges go to the cookie handler and return 401; only /bff/login redirects to Keycloak
        options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddCookie(options =>
    {
        // __Host- prefix: forces Secure, Path=/ and no Domain; subdomains cannot overwrite the cookie
        options.Cookie.Name = "__Host-bookstore";
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Lax;
        options.ExpireTimeSpan = TimeSpan.FromHours(8);
        options.SlidingExpiration = true;
        options.Events.OnRedirectToLogin = context =>
        {
            // 401 instead of a redirect for addresses the SPA calls
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return Task.CompletedTask;
        };
    })
    .AddOpenIdConnect(options =>
    {
        options.Authority = builder.Configuration["Oidc:Authority"];
        options.RequireHttpsMetadata = false; // local lab only: Keycloak runs on HTTP
        options.ClientId = builder.Configuration["Oidc:ClientId"];
        options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
        options.ResponseType = OpenIdConnectResponseType.Code;
        options.Scope.Add("books.read");
        options.Scope.Add("orders.read");
        options.MapInboundClaims = false;
        options.SaveTokens = true; // tokens live in the encrypted cookie and never reach JavaScript in the browser
        options.TokenValidationParameters.NameClaimType = "name";
    });

app.MapGet("/bff/login", (string? returnUrl) => Results.Challenge(
    new AuthenticationProperties { RedirectUri = IsLocal(returnUrl) ? returnUrl : "/" },
    [OpenIdConnectDefaults.AuthenticationScheme]));

app.MapGet("/bff/user", (ClaimsPrincipal user) => user.Identity?.IsAuthenticated == true
    ? Results.Ok(new { Sub = user.FindFirstValue("sub"), Name = user.Identity.Name, Email = user.FindFirstValue("email") })
    : Results.Unauthorized());

// Against open redirects: only return to in-app addresses
static bool IsLocal([NotNullWhen(true)] string? url) =>
    url is not null && url.StartsWith('/') && !url.StartsWith("//") && !url.StartsWith("/\\");

Two decisions matter. The first is that DefaultChallengeScheme is the cookie: on my first attempt I chose OIDC, and every unauthorized /api request from Angular was redirected to Keycloak with a 302; an XHR (XMLHttpRequest) can do nothing useful with that redirect. Now an unauthorized request gets a 401, and Angular starts sign-in with a full-page navigation to /bff/login. The second is the returnUrl check; the post-sign-in return address comes from outside, and if it is not validated the BFF becomes an open redirect.

For SameSite, RFC 10017 recommends Strict as a recommendation, not a requirement. I left it at Lax and based the CSRF (Cross-Site Request Forgery) defense on the header below; the document itself says SameSite alone is not enough if the BFF shares a site with other applications. If you choose Strict, test in a real browser whether the cookie is sent through the redirect chain coming back from the IdP (Identity Provider). Driving the sign-in with curl, the last step was:

$ sh login14.sh ada $J
HTTP/2 302
location: /orders
set-cookie: .AspNetCore.Correlation.cbG00xlo2JogDICKU4AV-Ppr1U46F23fAKi8E3qpzxE=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/signin-oidc; secure; sa...
set-cookie: .AspNetCore.OpenIdConnect.Nonce.CfDJ8EINq_wm4TFCv7guzy6SHCcNJV0FuWfwXAtBAX15ghV-FlBZqngUt63uPD_QVwAzlb92e6...
set-cookie: __Host-bookstore=chunks-2; path=/; secure; samesite=lax; httponly
set-cookie: __Host-bookstoreC1=CfDJ8EINq_wm4TFCv7guzy6SHCe2uLOq0iX51I3zPsu9Al8ZkrI2a9a_7joHS7gnYOyS3mppC_xwyt_1Q-UZVHd...
set-cookie: __Host-bookstoreC2=mXFQCXydyMwFIVnoCaIky8RU1oYCCSYGODtKgWOAhdDkACcVJUghxuaffBjXqTxH3VwUOpXKBD2bh0tLYsh7YBE...
$ curl -sk -b $J -H "X-CSRF: 1" https://localhost:19214/bff/user
{"sub":"813c410a-8e19-4979-85aa-5cc46f3cb5ab","name":"Ada Lovelace","email":"ada@bookstore.test"}

The browser holds a two-chunk cookie encrypted with Data Protection; the access, refresh and ID tokens are inside it. All Angular sees are the three fields /bff/user returns. If the cookie's size bothers you, keep the tokens on the server with ITicketStore from post 02 and leave only a key in the cookie.

YARP: Adding the Token on the Server

The BFF's second job is to forward /api/* requests from Angular to the API, removing the cookie and adding the access token. YARP is a reverse proxy library built for this:

builder.Services.AddReverseProxy()
    .LoadFromMemory(
        [
            new RouteConfig
            {
                RouteId = "api",
                ClusterId = "bookstore-api",
                AuthorizationPolicy = "default",
                Match = new RouteMatch { Path = "/api/{**rest}" },
                Transforms = [new Dictionary<string, string> { ["PathRemovePrefix"] = "/api" }]
            }
        ],
        [
            new ClusterConfig
            {
                ClusterId = "bookstore-api",
                Destinations = new Dictionary<string, DestinationConfig>
                {
                    ["api"] = new() { Address = builder.Configuration["Api:Address"]! }
                }
            }
        ])
    .AddTransforms(context =>
    {
        // The cookie does not go to the API; the user's access token goes instead
        context.AddRequestHeaderRemove("Cookie");
        context.AddRequestTransform(async transform =>
        {
            UserTokenRefresher refresher = transform.HttpContext.RequestServices.GetRequiredService<UserTokenRefresher>();
            string? accessToken = await refresher.GetAccessTokenAsync(transform.HttpContext);
            if (accessToken is not null)
            {
                transform.ProxyRequest.Headers.Authorization = new("Bearer", accessToken);
            }
        });
    });

AuthorizationPolicy = "default" restricts the route to signed-in users; the BFF never forwards an unauthorized request to the API. The route list is also the allowlist RFC 10017 asks for: the BFF proxies only to targets defined here, never to an address supplied by the client. The result:

$ curl -sk -b $J -H "X-CSRF: 1" https://localhost:19214/api/orders/mine
{"customer":"ada@bookstore.test","client":"bookstore-bff"}
$ curl -sk -b $J -H "X-CSRF: 1" https://localhost:19214/api/books
["Dune","Solaris","Neuromancer"]

The client field in the API's response comes from the azp claim: the API sees that the request came with a token the bookstore-bff client obtained on Ada's behalf. The audience, scope and end-user policies from post 13 worked without any change.

Token Renewal Is the BFF's Job

In the lab I lowered the access token lifetime of the bookstore-bff client to 60 seconds so the renewal becomes visible. When the access token is about to expire, the BFF gets a new one with the refresh token and updates the cookie:

// Renews the access token with the refresh token when it is about to expire and updates the tokens in the cookie
public sealed class UserTokenRefresher(
    IHttpClientFactory httpFactory, IOptionsMonitor<OpenIdConnectOptions> oidcOptions,
    TimeProvider clock, ILogger<UserTokenRefresher> logger)
{
    private static readonly TimeSpan RefreshBefore = TimeSpan.FromSeconds(30);

    public async Task<string?> GetAccessTokenAsync(HttpContext http)
    {
        AuthenticateResult auth = await http.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        if (!auth.Succeeded)
        {
            return null;
        }

        AuthenticationProperties properties = auth.Properties!;
        string? accessToken = properties.GetTokenValue("access_token");
        DateTimeOffset expiresAt = DateTimeOffset.Parse(properties.GetTokenValue("expires_at")!, CultureInfo.InvariantCulture);
        if (expiresAt - RefreshBefore > clock.GetUtcNow())
        {
            return accessToken;
        }

        OpenIdConnectOptions options = oidcOptions.Get(OpenIdConnectDefaults.AuthenticationScheme);
        string tokenEndpoint = (await options.ConfigurationManager!.GetConfigurationAsync(http.RequestAborted)).TokenEndpoint;
        using HttpResponseMessage response = await httpFactory.CreateClient().PostAsync(tokenEndpoint, new FormUrlEncodedContent(
        [
            new("grant_type", "refresh_token"),
            new("refresh_token", properties.GetTokenValue("refresh_token")!),
            new("client_id", options.ClientId!),
            new("client_secret", options.ClientSecret!)
        ]), http.RequestAborted);
        if (!response.IsSuccessStatusCode)
        {
            // The refresh token was revoked or expired: end the session, the SPA gets a 401 and starts a new sign-in
            logger.LogWarning("Refresh failed with {Status}; signing out", (int)response.StatusCode);
            await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            return null;
        }

        TokenResponse tokens = (await response.Content.ReadFromJsonAsync<TokenResponse>(http.RequestAborted))!;
        properties.UpdateTokenValue("access_token", tokens.AccessToken);
        properties.UpdateTokenValue("refresh_token", tokens.RefreshToken);
        properties.UpdateTokenValue("expires_at",
            clock.GetUtcNow().AddSeconds(tokens.ExpiresIn).ToString("o", CultureInfo.InvariantCulture));
        await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, auth.Principal!, properties);
        logger.LogInformation("Access token refreshed, new expiry in {Seconds}s", tokens.ExpiresIn);
        return tokens.AccessToken;
    }

    private sealed record TokenResponse(
        [property: JsonPropertyName("access_token")] string AccessToken,
        [property: JsonPropertyName("refresh_token")] string RefreshToken,
        [property: JsonPropertyName("expires_in")] int ExpiresIn);
}
t~0s: 200
t~20s: 200
t~35s: 200
info: Bookstore.Bff.UserTokenRefresher[0]
      Access token refreshed, new expiry in 60s

Angular knows nothing about this renewal; it sends the same cookie on every request, and the new cookie arrives with the response. In an app using a public client in the browser, Angular has to do this work itself; tying concurrent 401s to a single refresh is the subject of post 17. This class's gap is the very problem solved there: two concurrent requests of the same user can refresh at the same moment. Refresh token rotation (revokeRefreshToken) is off in Keycloak, so the lab has no problem; turn it on and the second refresh is rejected and the user drops out of the session. In that case lock the refresh per user, or use a library that does it, such as Duende.AccessTokenManagement.OpenIdConnect.

CSRF and Logout

The moment the session rests on a cookie, CSRF is back: a form on another site can make the browser send a request with the cookie to the BFF. RFC 10017's recommended defense is that every request carry a custom header; a request from another origin can add that header only by passing a CORS preflight, and the BFF grants CORS to no origin:

// Anti-CSRF: /api and /bff calls must carry a custom header; it cannot be sent cross-origin without a preflight
app.Use(async (context, next) =>
{
    bool protectedPath = context.Request.Path.StartsWithSegments("/api")
        || (context.Request.Path.StartsWithSegments("/bff") && !context.Request.Path.StartsWithSegments("/bff/login"));
    if (protectedPath && context.Request.Headers["X-CSRF"] != "1")
    {
        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        await context.Response.WriteAsync("Missing X-CSRF header");
        return;
    }
    await next();
});

On the Angular side a functional interceptor adds it; the header goes only to requests to our own origin:

import { HttpInterceptorFn } from '@angular/common/http';

// BFF anti-CSRF header: added only to requests to our own origin
export const csrfHeaderInterceptor: HttpInterceptorFn = (req, next) => {
  const sameOrigin = req.url.startsWith('/') && !req.url.startsWith('//');
  return next(sameOrigin ? req.clone({ setHeaders: { 'X-CSRF': '1' } }) : req);
};
// app.config.ts
provideHttpClient(withInterceptors([csrfHeaderInterceptor]))

Protocol-relative addresses like //cdn.example.com also start with /; the second condition is for them. The interceptor's test pins this down (Vitest, Angular 22's default test runner):

it('does not leak the header to other origins', () => {
  http.get('https://cdn.example.com/covers.json').subscribe();
  http.get('//cdn.example.com/covers.json').subscribe();
  expect(backend.expectOne('https://cdn.example.com/covers.json').request.headers.has('X-CSRF')).toBe(false);
  expect(backend.expectOne('//cdn.example.com/covers.json').request.headers.has('X-CSRF')).toBe(false);
});

A request without the header, for example a curl acting like another site's form:

$ curl -sk -b $J -o /dev/null -w "no csrf: %{http_code}\n" https://localhost:19214/api/books
no csrf: 400

Logout raised a small problem. The classic solution is to POST a form to /bff/logout and have the BFF redirect the browser to Keycloak; but a form cannot add a custom header, and an XHR cannot follow the cross-origin redirect to Keycloak. I split it in two: Angular POSTs with XHR, the BFF deletes the cookie and returns Keycloak's logout address as JSON (JavaScript Object Notation), and Angular takes the browser there:

// The SPA calls this with XHR: the cookie is deleted, Keycloak's logout address is returned and the SPA navigates there
app.MapPost("/bff/logout", async (HttpContext http, IOptionsMonitor<OpenIdConnectOptions> oidcOptions) =>
{
    string? idToken = await http.GetTokenAsync("id_token");
    await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    OpenIdConnectOptions options = oidcOptions.Get(OpenIdConnectDefaults.AuthenticationScheme);
    OpenIdConnectConfiguration config = await options.ConfigurationManager!.GetConfigurationAsync(http.RequestAborted);
    string logoutUrl = QueryHelpers.AddQueryString(config.EndSessionEndpoint, new Dictionary<string, string?>
    {
        ["id_token_hint"] = idToken,
        ["post_logout_redirect_uri"] = $"{http.Request.Scheme}://{http.Request.Host}/"
    });
    return Results.Ok(new { LogoutUrl = logoutUrl });
}).RequireAuthorization();
no csrf: 400
{"logoutUrl":"http://localhost:19200/realms/bookstore/protocol/openid-connect/logout?id_token_hint=eyJ...&post_logout_redirect_uri=https%3A%2F%2Flocalhost%3A19214%2F"}
302 https://localhost:19214/
user after logout: 401

On my first attempt I pointed post_logout_redirect_uri at the OIDC handler's /signout-callback-oidc; for a return it had not started itself (no state) the handler returned an empty 200. Since you start logout outside the handler, the return address should be the app's root directly and be registered that way in Keycloak.

Costs and Limits

A BFF is not free. Every API call adds a hop: browser → BFF → API. In the lab I averaged 30 requests with curl; excluding connection setup, a direct request to the API took 0.25 ms and one through the BFF 0.66 ms. All on the same machine, with no network latency; in a real deployment the network latency between the BFF and the API adds to this. The real cost isn't time: the BFF is now on the critical path; if it falls over, the app falls over. The BFF is stateful: even with the session in the cookie, the Data Protection key ring must be shared between instances, and so must the session store if you use ITicketStore. Large uploads and downloads, WebSockets and Server-Sent Events also pass through the proxy; YARP supports them, but timeouts and body limits must be set on the BFF as well.

The BFF's hardest limit is the same-origin requirement: Angular must be served from the BFF, or be on the same site as the BFF. If you serve Angular from a CDN (Content Delivery Network) on another domain, the cookie becomes a third-party cookie, and browsers block those more and more. RFC 10017 also says the BFF can be deployed as separate services with a CDN; the condition is that both live under the same site.

When is a public client in the browser acceptable? Where running a server-side component is genuinely impossible (a small tool on static hosting, low-risk data), and on these conditions: PKCE (Proof Key for Code Exchange), refresh token rotation or DPoP (Demonstrating Proof of Possession), short token lifetimes, a strict CSP (Content Security Policy). If there is payment, personal data or administration involved, use a BFF.

In Production

Close direct access to the API behind the BFF, or at least make sure the API works only with tokens; a request that bypasses the BFF must still pass the API's own validation. Keep the route list narrow: /api/{**rest} goes to a single API; with several APIs, use a separate route and a separately scoped token for each. Watch Set-Cookie headers in proxied responses too; if the API sets a cookie, the BFF passes it to the browser.

Make sure the middleware that checks the anti-CSRF header runs before UseAuthorization and covers every newly added /bff endpoint; the exception list (only /bff/login here) must stay short. Align the cookie lifetime with the refresh token lifetime: if the cookie lives 8 hours while the refresh token dies in 30 minutes, the user thinks the session is open and gets 401s.

If you want a ready-made solution, the Duende BFF Security Framework packages every part of this post (session management, token renewal, anti-CSRF, YARP integration, back-channel logout); it is commercially licensed, so check its terms at the time you write. Writing your own BFF takes about as much code as this post; the hard part is testing every edge case here.

Common Pitfalls

First, building a BFF and then returning the access token from /bff/user; the whole benefit of the architecture is gone, and it is now a token-mediating backend. Second, not putting an authorization policy on the YARP route; the BFF forwards anonymous requests without a token, and hard-to-debug 401s begin. Third, passing the Cookie header through to the API; session cookies pile up in the API's logs.

Fourth, treating SameSite as the only CSRF defense. Fifth, not validating returnUrl. Sixth, putting the BFF on a separate domain and "making it work" with SameSite=None; that both runs into third-party cookie blocking and widens the CSRF surface.

A Small Experiment

★☆☆ Set DefaultChallengeScheme to OpenIdConnectDefaults.AuthenticationScheme and call /api/books with the X-CSRF header without signing in. Write down the response and explain what an XHR could do with it.

★★☆ Turn on the realm's "Revoke Refresh Token" setting in Keycloak, and with a 60-second access token send two concurrent requests with the same cookie right at renewal time. Does one fail? Add a per-user lock to UserTokenRefresher and try again.

★★★ Take the tokens out of the cookie: write an ITicketStore on Redis or EF (Entity Framework) Core, show that the cookie shrinks to a single chunk, and that the back-channel logout from post 12 can now delete the session record directly.

Angular no longer sees tokens; the party that issues tokens and manages the session, though, has been Keycloak throughout these posts. In the next post we compare the options that could take that role: OpenIddict, Duende IdentityServer, Keycloak and Microsoft Entra ID; the difference between self-hosting and a managed service, licenses, standards support, and the "don't write your own authorization server" warning.