Back in early 2020, Dominick Baier, one of Duende’s founders, wrote a provocative post titled "SPAs are dead!?" that sent ripples through the identity community, warning of stricter cookie handling to come. At the time, Safari's Intelligent Tracking Prevention had begun blocking third-party cookies, Brave followed suit, and Chrome had announced vague plans to do the same "by 2022." The question on every developer's mind was whether these changes would fundamentally break how single-page applications handle authentication.
The answer, we now know definitively, is yes. Yes, they did. The Cookie Apocalypse already happened, and every SPA needs a BFF (Backend for Frontend).
Let's take a look at why that happened, and how it happened.
Every major browser has completed its third-party cookie deprecation. Chrome shipped its Privacy Sandbox initiative and removed third-party cookies for all users. Firefox's Total Cookie Protection, enabled by default since 2023, isolates cookies to the site that created them, making cross-site cookie access impossible. Safari's ITP matured into a comprehensive tracking-prevention system that blocks not just cookies but also a range of cross-site state mechanisms. Edge follows Chrome's Chromium-based implementation.
The "cookie apocalypse" that the identity community warned about in 2020 is not a future risk. It is the current reality for every browser your users use.
The practical consequences for OIDC-based SPAs are severe and non-negotiable. Silent token renewal via hidden iframes, the technique that oidc-client-js and its successor oidc-client-ts relied on, is broken. These libraries depended on loading the identity provider in a hidden iframe and reading the authentication response through cross-origin cookie access. That mechanism no longer functions. Front-channel logout, which relied on the same cross-site iframe pattern, is equally dead. OIDC session management, as specified in the OpenID Connect Session Management draft, used an iframe-based “check session” endpoint that could not maintain state across site boundaries.
If your SPA architecture depended on any of these patterns, it is already broken in production, whether you have noticed it yet or not.
What Does "SPA" Mean in 2026?
Dominick's original article contained an insight that has proven remarkably prescient:
"SPA as a UI/UX concept — certainly not dead."
He was right. The single-page application as an interaction model, with its fluid navigation, client-side state, and reactive interfaces, is thriving. What died was a very specific architectural pattern: the standalone browser application that performs cross-site authentication directly with an identity provider and stores tokens in JavaScript-accessible storage. That pattern was always a security compromise. The browser privacy changes simply made it untenable.
The modern landscape is better understood as a spectrum. On one end, you have fully server-rendered applications using Blazor Interactive Server, Next.js with server components, Nuxt with SSR, or SvelteKit. These approaches handle authentication entirely on the server and never expose tokens to the browser. In the middle, you have BFF-backed SPAs: rich client-side applications (React, Angular, Vue, Blazor WebAssembly) that delegate all authentication and API token management to a server-side Backend-for-Frontend host. At the far end, the one that no longer works, sits the fully decoupled SPA that handles its own OIDC flows and manages tokens in localStorage or sessionStorage.
What is striking about 2026 is how thoroughly the framework ecosystem converged on this understanding. Blazor moved to server-side and hybrid rendering as first-class citizens. Next.js App Router and Nuxt 3 default to server-side rendering with server-managed auth. SvelteKit treats server-side load functions as the standard pattern. The industry reached consensus not through ideology but through the hard constraints imposed by browser vendors.
The implication for .NET developers is clear. If you are building a new application, choose either a server-rendered model or a BFF-backed SPA. If you have an existing SPA that manages its own tokens, you need a migration plan. The sections that follow provide both the architectural guidance and the concrete implementation to make that happen.
The BFF Pattern Explained
The Backend-for-Frontend pattern introduces a server-side component that sits between the browser and your backend APIs. This host, not the browser, is the OAuth client. It performs the OIDC authorization code flow with PKCE, receives and stores access tokens and refresh tokens server-side, and proxies API calls on behalf of the browser. The browser authenticates to the BFF host using a traditional HTTP cookie. That cookie is the only credential that ever reaches the browser.
Here is the architecture at a glance:
graph LR
Browser["Browser<br/>(SPA)"]
BFF["BFF Host<br/>(.NET 10)"]
API["Backend API"]
IDP["Identity<br/>Provider"]
Browser <-->|"Cookie<br/>(HttpOnly, SameSite)"| BFF
BFF -->|"Access Token<br/>(Bearer JWT)"| API
API -.->|Response| BFF
BFF -->|"OIDC<br/>(Code + PKCE)"| IDP
Here is the step-by-step BFF authentication flow:
- The Trigger: The user clicks "Log in" in their browser. Instead of the frontend handling the login, the BFF host takes over, redirecting the user to the Identity Provider (IdP).
- Authentication: The user logs in at the IdP. Once they are verified, the IdP sends them back to your BFF host with a temporary authorization code.
- The Server-Side Swap: Now the magic happens. The BFF host exchanges that code for a set of tokens (Access, Refresh, and ID tokens). This happens "back-channel"—server-to-server—so the browser never sees the actual tokens.
- Secure Storage: Instead of handing those tokens to the frontend, the BFF stores them in a server-side session store (such as Redis or an SQL database).
- The "Key" to the Session: The BFF then sends an encrypted session cookie back to the browser. To keep it locked down, it’s marked as
HttpOnly,Secure, andSameSite=Strict. - The Automatic Handshake: From here on, every time the browser makes an API request, it automatically includes that cookie. Your JavaScript code doesn't even have to touch it.
- Relaying the Request: The BFF receives the request, validates the cookie, and looks up the corresponding access token in its database. It then attaches that token as a Bearer token and forwards the request to your downstream API.
Here is a sequence diagram for those more visually inclined.
sequenceDiagram
autonumber
participant U as User / Browser
participant B as BFF Host (Server)
participant I as Identity Provider (IdP)
participant A as Downstream API
U->>B: Click "Log In"
B->>I: Redirect to Auth Page
I->>U: Show Login UI
U->>I: Provide Credentials
I->>B: Redirect with Auth Code
Note over B,I: Back-channel Exchange
B->>I: Exchange Code for Tokens
I->>B: ID, Access, & Refresh Tokens
Note right of B: Store tokens in Redis/SQL
B->>U: Issue HttpOnly, Secure Cookie
Note over U,B: Subsequent API Calls
U->>B: Request + Session Cookie
B->>B: Validate Cookie & Retrieve Access Token
B->>A: Forward Request + Bearer Token
A->>B: API Response
B->>U: Final Data Response
The BFF architecture is fundamentally more secure than any scheme that places tokens in the browser. HttpOnly cookies cannot be read by JavaScript, so even a successful XSS attack cannot exfiltrate the user's access or refresh token. SameSite=Strict cookies are not sent on cross-site requests, which eliminates CSRF as an attack vector without requiring anti-forgery tokens. The access token, the credential that grants access to your APIs, never appears in browser-accessible storage, never traverses the browser's JavaScript runtime, and cannot be extracted from localStorage, sessionStorage, or a service worker cache. The attack surface shrinks from "any XSS vulnerability can steal long-lived tokens and replay them from anywhere" to "an attacker must maintain an active XSS session to make requests through the user's browser." That is a categorically different threat model.
What About Refresh Tokens in the Browser?
We can’t say this anymore clearly: Do not store refresh tokens in the browser. This guidance is not tentative, and it has not softened with time. A refresh token is a long-lived credential that can silently obtain new access tokens, making it the single most valuable target for an attacker who achieves XSS in your application. Storing sensitive data in localStorage or sessionStorage allows any injected script to exfiltrate it and use it from an entirely separate machine, long after the user has closed their browser.
Some have argued that DPoP (Demonstrating Proof of Possession, RFC 9449) mitigates this risk by binding tokens to a cryptographic key. In a server context, DPoP is valuable. In the browser, however, the DPoP private key must be stored in a CryptoKey object that, while not directly exportable, is still accessible to any JavaScript running in the same origin — which is precisely the threat model that XSS represents. DPoP raises the bar for an attacker, but it does not close the window. The BFF pattern avoids the question entirely: refresh tokens live on the server, in memory, or in an encrypted session store, where no browser-side code can reach them.
The answer for the vast majority of applications is to use a BFF.
Current Browser Privacy Landscape
Understanding the browser privacy landscape is essential context for why the BFF pattern is no longer optional. Each major browser has independently implemented its own flavor of cross-site tracking prevention, and while the technical details differ, the outcome is the same: cross-site cookies and cross-site state are inaccessible by default.
Chrome's Privacy Sandbox replaced third-party cookies with a set of purpose-built APIs (Topics, Protected Audiences, and Attribution Reporting) that provide limited, privacy-preserving alternatives for advertising use cases. For authentication, there is no replacement; cross-site cookie access is simply gone. Firefox's Total Cookie Protection creates a separate cookie jar for every website, meaning a cookie set by identity.example.com when embedded in an iframe on app.example.com is invisible when the user navigates directly to identity.example.com. Safari's Intelligent Tracking Prevention, the earliest and most aggressive implementation, not only blocks third-party cookies but also caps the lifetime of first-party cookies set via JavaScript and restricts document.referrer in cross-site navigations.
| Browser | Third-Party Cookies | SameSite Default | Partitioned Storage |
|---|---|---|---|
| Chrome | Blocked (Privacy Sandbox) | Lax | Yes |
| Firefox | Total Cookie Protection | Lax | Yes |
| Safari | Full ITP | Lax | Yes |
| Edge | Follows Chrome | Lax | Yes |
The SameSite=Lax default across all browsers means that even first-party cookies are not sent on cross-site POST requests. Partitioned storage (the CHIPS proposal, now implemented) allows opt-in partitioned cookies for legitimate embedded use cases, but these are per top-level site and do not help with OIDC session management. The direction of travel is clear: browsers will only become more restrictive. Any architecture that depends on cross-site state sharing is built on a foundation that is being actively removed.
Building a BFF with Duende.BFF v4 and .NET 10
Duende has created the Duende.BFF library to help .NET developers follow the BFF pattern when building web-based applications. It is a purpose-built library from the team behind IdentityServer that handles the full lifecycle, including OIDC authentication, token management, session storage, API proxying, anti-forgery protection, and logout coordination, with minimal configuration.
BFF v4 streamlines the setup further with a unified fluent API that automatically configures cookie authentication and OpenID Connect, so you no longer need to wire up AddAuthentication, AddCookie, and AddOpenIdConnect separately.
Here is a complete BFF host using Duende.BFF v4 and .NET 10 minimal APIs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddBff()
.ConfigureOpenIdConnect(options =>
{
options.Authority = "https://your-identity-server";
options.ClientId = "bff-client";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.ResponseMode = "query";
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
options.MapInboundClaims = false;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("api");
})
.ConfigureCookies(options =>
{
// Use Lax if your identity provider is on a different origin;
// Strict if it shares the same site.
options.Cookie.SameSite = SameSiteMode.Lax;
})
// Enables MapRemoteBffApiEndpoint (requires Duende.BFF.Yarp)
.AddRemoteApis()
// Persists sessions server-side (in-memory by default)
.AddServerSideSessions();
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseRouting();
app.UseBff();
app.UseAuthorization();
// Proxy API calls through the BFF with automatic access token attachment
app.MapRemoteBffApiEndpoint("/api", new Uri("https://api.example.com"))
.WithAccessToken(RequiredTokenType.User)
.RequireAuthorization();
app.MapFallbackToFile("index.html");
app.Run();
Notice how little ceremony is involved. AddBff() with its fluent ConfigureOpenIdConnect and ConfigureCookies methods replaces the manual registration of authentication schemes. BFF v4 configures the cookie and OIDC handlers for you with secure defaults, including an HttpOnly, Secure cookie with a __Host- prefix. BFF management endpoints /bff/login, /bff/logout, /bff/user, and /bff/backchannel are auto-mapped in single-frontend mode, so there is no explicit MapBffManagementEndpoints() call required (though you can add one if you need to customize paths).
MapRemoteBffApiEndpoint() proxies requests to your downstream API and automatically attaches the user's access token via WithAccessToken(RequiredTokenType.User), refreshing it transparently when it expires. Duende.BFF also supports distributed session storage (Redis, SQL Server via Duende.BFF.EntityFramework) and back-channel logout coordination.
Migration Guide: From In-Browser Tokens to BFF
If you have an existing SPA that uses oidc-client-ts (or the older oidc-client-js) to manage authentication directly in the browser, migrating to a BFF does not require rewriting your frontend. The key insight is that the BFF is an additive change to your backend infrastructure, and your SPA's UI code, routing, and API call patterns remain largely unchanged.
Step 1: Add a .NET BFF host. Create a new ASP.NET Core project (or extend your existing API host) that serves your SPA's static files. Configure cookie authentication and OpenID Connect as shown in Section 4. This host becomes the origin of your SPA, and the browser loads index.html and all its assets.
Step 2: Move OIDC configuration to the server. Remove oidc-client-ts and its UserManager configuration from your frontend code. Replace the login trigger with a simple redirect to /bff/login. Replace the logout trigger with a redirect to /bff/logout. Replace the user info check with a fetch to /bff/user. There is no more signinRedirectCallback, no silent renew, and no token expiration handling in JavaScript.
Step 3: Proxy API calls through the BFF. Configure Duende.BFF's MapRemoteBffApiEndpoint() so that your SPA's API calls, which were previously aimed at https://api.example.com/resource,now target /api/resource on the same origin. The BFF proxies these requests to the downstream API, attaching the access token. Update your frontend's base URL from the external API domain to a relative /api path.
Step 4: Handle the cookie lifecycle. Your SPA no longer manages tokens, but it does need to handle the cookie session. When a fetch to /bff/user returns null or a 401, redirect the user to /bff/login. Use the credentials: 'same-origin' fetch option (the default) to ensure cookies are sent with every request. If your SPA previously used an Axios or fetch interceptor to attach a Bearer token, remove it, as the browser automatically attaches the cookie.
Step 5: Test cross-site scenarios. Verify that your application works correctly when third-party cookies are blocked (they should, since you no longer depend on them). Test logout across multiple tabs. Confirm that the SameSite=Strict cookie policy does not interfere with your authentication redirects. If you use external identity providers that redirect back to your app, you may need SameSite=Lax instead of Strict to ensure the cookie is sent on the redirect. Run your full test suite against Chrome, Firefox, and Safari to validate that no residual cross-site dependencies remain.
The migration is typically a matter of days, not weeks, because the SPA frontend itself changes minimally. The heaviest work is setting up the BFF host and configuring Duende.BFF's endpoint mapping. Work that is mechanical, well-documented, and done once.
Alternatives and Complementary Patterns
Blazor Server and Interactive SSR
Blazor Server and the Interactive Server render mode in .NET 10 represent the most secure option for browser-based applications. All code execution, state management, and authentication happen on the server. The browser receives only rendered HTML over a SignalR connection. No tokens, no cookies carrying authentication material, and no JavaScript-accessible credentials exist in the browser at all. The tradeoff is latency sensitivity and server resource consumption, but for internal line-of-business applications, this model is compelling.
Blazor WebAssembly with BFF Backend
Blazor WebAssembly runs .NET code in the browser via WebAssembly, which gives it the feel of a traditional SPA. Despite running in C# rather than JavaScript, it faces the same browser security constraints as any client-side application. WebAssembly modules have full access to the same-origin storage and DOM. The correct architecture pairs Blazor WASM with a .NET BFF host that handles authentication and API proxying. The Blazor WASM app authenticates via cookie to its .NET host and never directly interacts with the identity provider.
React, Angular, or Vue with a .NET BFF Host
This is the most common BFF deployment model. Your SPA framework of choice serves its static assets from the .NET BFF host (or from a CDN with the BFF as the API origin). The SPA makes API calls to same-origin /api/* endpoints, which the BFF proxies to downstream services, attaching access tokens. From the frontend's perspective, authentication is trivial: a cookie arrives automatically with every request. No oidc-client-ts, no token interceptors, no silent renew timers.
Token Handler Pattern (API Gateway as BFF)
The Token Handler pattern, promoted and adopted by several API gateway vendors, moves the BFF logic into the API gateway layer. The gateway handles OIDC flows, manages tokens, and issues cookies to the browser, while your application APIs remain pure resource servers. This is architecturally equivalent to the BFF pattern but centralizes the authentication concern at the infrastructure level. It is a strong choice for organizations that already have a gateway (Kong, NGINX, Azure API Management) and prefer to keep their application code free of auth plumbing. That said, adopting this approach does mean development teams lose the freedom and control that comes with an SDK-based approach.
Conclusion
The BFF pattern is not an alternative approach to securing browser-based applications; it is the standard. The IETF's OAuth 2.0 for Browser-Based Applications BCP recommends it. The browser vendors have made it necessary by removing the cross-site mechanisms that previous architectures depended on. The security community has converged on it as the only architecture that keeps high-value credentials out of the browser's JavaScript runtime. And the framework ecosystem, from .NET to Next.js to SvelteKit, has adopted it as the default.
For .NET developers, the path is well-paved. Duende.BFF provides a production-ready, commercially supported implementation. Blazor's server-side and hybrid rendering modes offer even tighter security for teams willing to adopt them. The tooling is mature, the patterns are proven, and the browser constraints leave no room for debate.
Browser privacy and security will only get stricter. The transition from "third-party cookies might be blocked" to "third-party cookies are blocked everywhere" took six years. The next wave of further restrictions on first-party cookie lifetimes, stricter referrer policies, and tighter storage partitioning is already underway. Architectures that handle tokens server-side and present only HttpOnly cookies to the browser are not just secure today; they are resilient to whatever browsers do next. Build your applications accordingly.
Thanks for stopping by!
We hope this post helped you on your identity and security journey. If you need a hand with implementation, our docs are always open. For everything else, come hang out with the team and other developers on GitHub.
If you want to get early access to new features and products while collaborating with experts in security and identity standards, join us in our Duende Product Insiders program. And if you prefer your tech content in video form, our YouTube channel is the place to be. Don't forget to like and subscribe!
Questions? Comments? Just want to say hi? Leave a comment below and let's start a conversation.