Secure a Blazor WASM ASP.NET Core hosted APP using BFF and OpenIddict

One question I keep getting from teams building Blazor WASM applications is where the access token should actually live. The default templates encourage you to store it in the browser and attach it to every API call, and that works fine in a demo. In a real production app, especially one dealing with sensitive data, that approach opens up a genuine attack surface. This is where the Backend for Frontend pattern, or BFF, earns its place.

In this article I will walk through how to secure a Blazor WASM app that is hosted inside ASP.NET Core, using the BFF architecture with OpenIddict as the identity provider. The core idea is simple to state and a bit more involved to implement correctly: no token ever reaches the browser. The server holds everything, and the Blazor client is treated as nothing more than a view into a trusted backend.

Why Keep Tokens Out of the Browser at All

A Single Page Application that stores access tokens in local storage or even in memory is still exposed to any XSS vulnerability in your own code or in a third party script you depend on. Once an attacker can run JavaScript in your page, the token is theirs. I have seen this argument dismissed as theoretical more than once, until a client’s own dependency scan turned up a compromised npm package doing exactly this.

With the BFF pattern, the browser only ever holds a same site, HTTP only session cookie. There is no token for a script to steal, because there is no token in the browser at all. The Blazor WASM app and its ASP.NET Core host are deployed together as one application on one domain, so the cookie based session works without any cross origin complications.

How the Pieces Fit Together

The setup has three moving parts. OpenIddict acts as the OpenID Connect server. The ASP.NET Core host, which also serves the compiled Blazor WASM files, acts as the OpenID Connect client using the authorization code flow with PKCE plus a confidential client secret. And the Blazor WASM UI itself never talks to the identity provider directly, it only calls APIs on its own host, protected by the session cookie.

Architecture overview: Blazor WASM served by an ASP.NET Core host acting as the confidential OIDC client, with OpenIddict as the identity provider.
Architecture overview: Blazor WASM served by an ASP.NET Core host acting as the confidential OIDC client, with OpenIddict as the identity provider.

Registering the Client in OpenIddict

The identity provider needs to know about our Blazor client before anything else works. This is done once at startup, usually inside a hosted service that implements IHostedService. The registration below sets up a confidential client using the authorization code flow, requires PKCE even though a secret is present, and adds the standard set of scopes the app will need.

static async Task RegisterApplicationsAsync(IServiceProvider provider)
{
    var manager = provider.GetRequiredService<IOpenIddictApplicationManager>();
 
    if (await manager.FindByClientIdAsync("blazorcodeflowpkceclient") is null)
    {
        await manager.CreateAsync(new OpenIddictApplicationDescriptor
        {
            ClientId = "blazorcodeflowpkceclient",
            ConsentType = ConsentTypes.Explicit,
            DisplayName = "Blazor code PKCE",
            RedirectUris = { new Uri("https://localhost:5001/signin-oidc") },
            PostLogoutRedirectUris = { new Uri("https://localhost:5001/signout-callback-oidc") },
            ClientSecret = "codeflow_pkce_client_secret",
            Permissions =
            {
                Permissions.Endpoints.Authorization,
                Permissions.Endpoints.Logout,
                Permissions.Endpoints.Token,
                Permissions.Endpoints.Revocation,
                Permissions.GrantTypes.AuthorizationCode,
                Permissions.GrantTypes.RefreshToken,
                Permissions.ResponseTypes.Code,
                Permissions.Scopes.Email,
                Permissions.Scopes.Profile,
                Permissions.Scopes.Roles
            },
            Requirements = { Requirements.Features.ProofKeyForCodeExchange }
        });
    }
}

Notice the FindByClientIdAsync check at the top. Without it, this method would try to create a duplicate client registration on every application restart and throw. It is a small thing but it trips up people the first time they wire this up, since the exception message does not immediately point at the real cause.

Wiring Up the Host as an OIDC Client

On the ASP.NET Core side, the host application is configured to use cookie authentication as its default scheme and OpenID Connect as the challenge scheme. This means a normal browser request checks the cookie first, and only redirects to the identity provider when there is no valid session yet. SaveTokens is set to true so the resulting tokens are stashed inside the encrypted authentication cookie rather than sent anywhere near the client.

services.AddAntiforgery(options =>
{
    options.HeaderName = "X-XSRF-TOKEN";
    options.Cookie.Name = "__Host-X-XSRF-TOKEN";
    options.Cookie.SameSite = SameSiteMode.Strict;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
 
var openIDConnectSettings = Configuration.GetSection("OpenIDConnectSettings");
 
services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
    options.SignInScheme = "Cookies";
    options.Authority = openIDConnectSettings["Authority"];
    options.ClientId = openIDConnectSettings["ClientId"];
    options.ClientSecret = openIDConnectSettings["ClientSecret"];
    options.RequireHttpsMetadata = true;
    options.ResponseType = "code";
    options.UsePkce = true;
    options.Scope.Add("profile");
    options.Scope.Add("offline_access");
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;
});
 
services.AddControllersWithViews(options =>
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));

The AutoValidateAntiforgeryTokenAttribute filter is doing real work here. Because we are relying on a cookie for authentication, every state changing API call needs CSRF protection, otherwise a malicious site could ride on the user’s session cookie and fire requests on their behalf. This is the piece people forget when they copy a token based auth sample and try to convert it to cookie based, and then wonder why their POST requests start failing once they add this filter.

The Authority, ClientId and ClientSecret values are read from configuration rather than hard coded, which is what lets the same code run against a local OpenIddict instance during development and a hosted identity provider in production.

"OpenIDConnectSettings": {
  "Authority": "https://localhost:44395",
  "ClientId": "blazorcodeflowpkceclient",
  "ClientSecret": "codeflow_pkce_client_secret"
}

Keep in mind that a client secret sitting in appsettings.json is fine for a demo but not for production. Move it to a proper secret store, Azure Key Vault or User Secrets locally, before this goes anywhere near a real deployment.

Locking Down the Response Headers

Cookie based auth alone is not enough, you also want a tight Content Security Policy and the usual set of security headers so the app resists clickjacking, MIME sniffing and mixed content issues. The NetEscapades.AspNetCore.SecurityHeaders package makes this configuration readable instead of a wall of manual header writes.

public static HeaderPolicyCollection GetHeaderPolicyCollection(bool isDev, string idpHost)
{
    var policy = new HeaderPolicyCollection()
        .AddFrameOptionsDeny()
        .AddXssProtectionBlock()
        .AddContentTypeOptionsNoSniff()
        .AddReferrerPolicyStrictOriginWhenCrossOrigin()
        .AddCrossOriginOpenerPolicy(b => b.SameOrigin())
        .AddCrossOriginResourcePolicy(b => b.SameOrigin())
        .AddCrossOriginEmbedderPolicy(b => b.RequireCorp()) // remove for hot reload in dev
        .AddContentSecurityPolicy(builder =>
        {
            builder.AddObjectSrc().None();
            builder.AddBlockAllMixedContent();
            builder.AddImgSrc().Self().From("data:");
            builder.AddFormAction().Self().From(idpHost);
            builder.AddFontSrc().Self();
            builder.AddStyleSrc().Self();
            builder.AddBaseUri().Self();
            builder.AddFrameAncestors().None();
            builder.AddScriptSrc().Self().UnsafeEval(); // required by current Blazor WASM runtime
        })
        .RemoveServerHeader();
 
    if (!isDev)
    {
        policy.AddStrictTransportSecurityMaxAgeIncludeSubDomains(maxAgeInSeconds: 60 * 60 * 24 * 365);
    }
 
    return policy;
}

The UnsafeEval entry on script-src looks alarming the first time you see it in a security focused article, but it is currently required for the Blazor WASM runtime to execute. Keep an eye on this as the Blazor runtime evolves, since tightening this policy further becomes possible as WASM tooling improves. Also remember to strip AddCrossOriginEmbedderPolicy during local development if you rely on hot reload, and never ship that relaxed policy to production.

Protecting the Backend APIs

Every API endpoint the Blazor UI calls needs both the antiforgery check and the Authorize attribute pinned to the cookie scheme explicitly. Leaving the scheme unspecified sometimes works by accident in a single scheme app, and then breaks the moment you add a second authentication scheme for something like a service to service call.

[ValidateAntiForgeryToken]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
[ApiController]
[Route("api/[controller]")]
public class DirectApiController : ControllerBase
{
    [HttpGet]
    public IEnumerable<string> Get()
    {
        return new List<string> { "some data", "more data", "loads of data" };
    }
}

What the Sign-In Flow Looks Like

Once this is wired up, a user hitting the Blazor app gets redirected to the OpenIddict login screen, authenticates, and comes back to an app that has never seen a raw access token. From the browser’s point of view, it is just a cookie.

Sign-in screen served by OpenIddict during the authorization code flow.
Sign-in screen served by OpenIddict during the authorization code flow.

When This Pattern Is Not the Right Fit

The BFF pattern assumes your Blazor UI and its APIs live behind the same trusted backend. If your architecture genuinely needs the UI to call APIs on a completely separate domain that you do not control, you are looking at a different problem, and a reverse proxy like YARP, a service to service call from your trusted backend, or an on behalf of flow are the usual options, not a token sitting in the browser.

It is also worth being upfront that this adds a bit of operational overhead compared to a pure SPA with a public API. You now have session state to manage server side, and horizontal scaling needs sticky sessions or a distributed cache for that session data. For an internal line of business app or anything handling sensitive data, that trade off is almost always worth it. For a low risk public app with no sensitive operations, a simpler token based setup might be less work to run day to day.

Closing Thoughts

The BFF pattern is not a new idea, but it maps particularly well onto Blazor WASM because the hosting model already puts the UI and the backend on the same origin. Once you accept that the browser should never hold a token, most of the rest of the configuration above falls out naturally from that one decision.

If you are evaluating this for a new project, start with the OpenIddict registration and cookie configuration first, get a working sign-in loop, and only then layer on the CSP and security headers. Trying to get the full header policy right on the first pass alongside a new auth flow tends to produce a lot of confusing trial and error.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading