RO EN

Secure Blazor WebAssembly — security when the code runs in the browser

Secure Blazor WebAssembly — security when the code runs in the browser
Doru Bulubașa
18 May 2026

Blazor WebAssembly is a fundamental shift from everything we've discussed so far in the series. Your compiled C# code runs in the client's browser, not on the server. This means that security rules change significantly — some things become simpler, others become impossible.

The most important principle of this article: anything running in WASM can be inspected, modified, and forged by the user. Blazor WASM is a client application, not a trusted server. Real security lives on the server — WASM views and respects it, but cannot guarantee it alone.


1. Blazor WASM vs. Blazor Server — security differences

Before implementation, it is worth understanding the fundamental differences between the two Blazor models from a security perspective:

Aspect Blazor Server Blazor WebAssembly
Where the code runs Server (SignalR) Browser (WASM)
Database access Direct (EF Core, etc.) Only via HTTP API
Application secrets Remain on the server No secrets — everything is visible
Authorization logic On the server, trusted On the client, only UX — server validates
Authentication tokens HttpOnly cookie, invisible to JS In memory or localStorage, accessible by JS
XSS attack surface Smaller (token invisible to JS) Larger (token accessible from JS)

Practical conclusion: in Blazor WASM, the backend API is the last line of defense. There is no shortcut.


2. Authentication with OIDC in Blazor WASM

Blazor WASM uses Authorization Code Flow with PKCE for authentication — exactly what we discussed in the OAuth2/OIDC article, but implemented on the client. There is no client secret (it is a public application), security comes from PKCE.

2.1 Package installation

dotnet add package Microsoft.AspNetCore.Components.WebAssembly.Authentication

2.2 Configuration in Program.cs (client)

// Program.cs — Blazor WASM client project
builder.Services.AddOidcAuthentication(options =>
{
    // Read from wwwroot/appsettings.json
    builder.Configuration.Bind("OidcConfiguration", options.ProviderOptions);

    options.ProviderOptions.ResponseType = "code"; // Authorization Code Flow
    options.ProviderOptions.DefaultScopes.Add("openid");
    options.ProviderOptions.DefaultScopes.Add("profile");
    options.ProviderOptions.DefaultScopes.Add("myapi");
});

2.3 wwwroot/appsettings.json

{
  "OidcConfiguration": {
    "Authority": "https://your-identityserver.com",
    "ClientId": "blazor-wasm-client",
    "PostLogoutRedirectUri": "https://localhost:5001/authentication/logout-callback",
    "RedirectUri": "https://localhost:5001/authentication/login-callback",
    "ResponseType": "code"
  }
}

Attention: wwwroot/appsettings.json is a public file — anyone can download this file from the browser. Never put secrets, connection strings, or API keys here. Only public configuration: Authority URL, ClientId, redirect URIs.

2.4 Authentication page — Authentication.razor

@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

<RemoteAuthenticatorView Action="@Action">
    <LoggingIn>
        <p>Redirecting to authentication...</p>
    </LoggingIn>
    <CompletingLoggingIn>
        <p>Completing authentication...</p>
    </CompletingLoggingIn>
</RemoteAuthenticatorView>

@code {
    [Parameter] public string? Action { get; set; }
}

2.5 App.razor — global protection with CascadingAuthenticationState

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData"
                                DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (!context.User.Identity?.IsAuthenticated ?? true)
                    {
                        <RedirectToLogin />
                    }
                    else
                    {
                        <p>You do not have the necessary permissions for this page.</p>
                    }
                </NotAuthorized>
                <Authorizing>
                    <p>Checking authentication...</p>
                </Authorizing>
            </AuthorizeRouteView>
        </Found>
    </Router>
</CascadingAuthenticationState>

3. Route and component protection

3.1 [Authorize] on pages

@page "/dashboard"
@attribute [Authorize]

<h1>Dashboard</h1>
<p>Content visible only to authenticated users.</p>
@page "/admin"
@attribute [Authorize(Roles = "Admin")]

<h1>Admin Panel</h1>

3.2 AuthorizeView in components

<AuthorizeView>
    <Authorized>
        <p>Welcome, @context.User.Identity?.Name!</p>
        <button @onclick="DoSomethingSecure">Secure action</button>
    </Authorized>
    <NotAuthorized>
        <p>Please log in.</p>
    </NotAuthorized>
</AuthorizeView>

<!-- With roles -->
<AuthorizeView Roles="Admin,Manager">
    <Authorized>
        <button>Delete user</button>
    </Authorized>
</AuthorizeView>

<!-- With policy -->
<AuthorizeView Policy="CanExportData">
    <Authorized>
        <button>Export CSV</button>
    </Authorized>
</AuthorizeView>

Important reminder: [Authorize] and AuthorizeView in Blazor WASM are purely UI — they hide or show visual elements. A user inspecting the WASM code can bypass these checks. Real authorization happens on the API.


4. Secured API calls — IHttpClientFactory with automatic token

Blazor WASM offers an elegant mechanism to automatically attach the access token to HTTP calls, without manually handling each request.

4.1 HttpClient configuration with token handler

// Program.cs
builder.Services.AddHttpClient("SecureApiClient",
    client => client.BaseAddress = new Uri("https://api.myapp.com"))
    .AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();

// Registration also for direct injection
builder.Services.AddScoped(sp =>
    sp.GetRequiredService<IHttpClientFactory>()
      .CreateClient("SecureApiClient"));

BaseAddressAuthorizationMessageHandler automatically attaches Authorization: Bearer <token> to every request to the base address — and transparently manages token refresh.

4.2 Usage in components

@inject HttpClient Http
@inject NavigationManager Navigation

@code {
    private List<ProductDto>? _products;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            _products = await Http.GetFromJsonAsync<List<ProductDto>>(
                "api/products");
        }
        catch (AccessTokenNotAvailableException ex)
        {
            // Token not available — redirect to login
            ex.Redirect();
        }
        catch (HttpRequestException ex) when
            (ex.StatusCode == HttpStatusCode.Forbidden)
        {
            // Authenticated, but without permissions
            Navigation.NavigateTo("/access-denied");
        }
    }
}

4.3 AuthorizationMessageHandler for multiple APIs

If your application calls multiple APIs with different domains, BaseAddressAuthorizationMessageHandler is not enough — it sends the token only to the base address. Use AuthorizationMessageHandler with explicit URLs:

builder.Services.AddHttpClient("ExternalApiClient",
    client => client.BaseAddress = new Uri("https://external-api.com"))
    .AddHttpMessageHandler(sp =>
        sp.GetRequiredService<AuthorizationMessageHandler>()
          .ConfigureHandler(
              authorizedUrls: new[]
              {
                  "https://external-api.com",
                  "https://another-api.com"
              },
              scopes: new[] { "myapi", "external-api" }));

5. Token storage — where and why it matters

The Microsoft.AspNetCore.Components.WebAssembly.Authentication library stores tokens in memory (internal session storage, not browser localStorage or sessionStorage). This is the correct choice from a security perspective:

Storage location Persistent after refresh Accessible from JS Vulnerable to XSS Recommended
Memory (default WASM) No No (WASM isolated) Minimal ✅ Yes
sessionStorage No (current tab) Yes Yes ⚠️ With caution
localStorage Yes Yes Yes — persistent! ❌ No
HttpOnly cookie Yes (configured) No No ✅ Yes (Hosted WASM)

The downside of memory storage: on page refresh, the user is redirected to login or a silent refresh happens via iframe (if the Authorization Server supports it). This is an acceptable limitation compared to the security risk of localStorage.


6. What you cannot hide in Blazor WASM

This is the section many ignore and which causes the most serious security mistakes in Blazor WASM applications.

Compiled source code

.NET assemblies are downloaded into the browser and can be inspected with any .NET decompiler (ILSpy, dnSpy, dotPeek). A determined user can see:

  • Business logic from components and services
  • The API URLs you call
  • The structure of data models
  • Any hardcoded string in code

What this means in practice

// DO NOT DO THIS in Blazor WASM — the key is visible to anyone
private const string ApiKey = "sk-live-abc123xyz";

// DO NOT DO THIS — connection string visible
private const string ConnectionString =
    "Server=prod-db;Database=MyApp;Password=secret";

// DO NOT DO THIS — pricing logic manipulable on client
private decimal CalculatePrice(Product p)
    => p.BasePrice * 0.8m; // 20% discount — can be manipulated
// CORRECT — all sensitive stuff remains on the server
// Client calls API, server calculates price
var price = await Http.GetFromJsonAsync<PriceDto>(
    $"api/products/{productId}/price");

Authorization checks in WASM

// UNSAFE — security check on client
@code {
    private async Task DeleteUser(int userId)
    {
        // This check can be bypassed
        if (!await AuthService.IsAdminAsync())
            return;

        await Http.DeleteAsync($"api/users/{userId}");
        // API MUST also check permissions
    }
}

The check in WASM is UX — prevents accidental clicks. The check in the API is security — prevents intentional attacks.


7. Securing the backend API — mandatory

We discussed API authorization extensively in articles #4 (Role-based vs. Policy-based), #5 (Claims transformation), and #8 (IdentityServer/Keycloak). In the Blazor WASM context, the rules apply without exception. A concise reminder:

// API Controller — full validation regardless of what the WASM client does
[ApiController]
[Route("api/[controller]")]
[Authorize]  // authentication required
public class OrdersController : ControllerBase
{
    [HttpDelete("{id}")]
    [Authorize(Policy = "CanDeleteOrders")]  // granular authorization
    public async Task<IActionResult> Delete(int id)
    {
        // Check that the resource belongs to the current user
        // Do not rely on Blazor having filtered this already
        var order = await _repo.GetByIdAsync(id);

        if (order is null)
            return NotFound();

        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        if (order.UserId != userId)
            return Forbid(); // user does not own this resource

        await _repo.DeleteAsync(id);
        return NoContent();
    }
}

8. Hosted Blazor WASM — security advantages

Blazor WASM can be hosted by an ASP.NET Core server (Hosted mode). This opens additional security options compared to standalone:

Cookie-based auth with BFF (Backend for Frontend)

The BFF pattern moves token management completely to the server, eliminating their exposure in the browser:

// Server project (ASP.NET Core host)
// Manages OIDC session and exposes proxy APIs to downstream
builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie(options =>
    {
        options.Cookie.HttpOnly   = true;
        options.Cookie.SameSite   = SameSiteMode.Strict;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    })
    .AddOpenIdConnect(options =>
    {
        options.Authority    = "https://your-idp.com";
        options.ClientId     = "bff-client";
        options.ClientSecret = "secret"; // on server, so safe
        options.SaveTokens   = true;
    });

// Blazor WASM calls the local server via HttpOnly cookie
// JWT token never reaches the browser

BFF is the recommended pattern for applications with high security requirements — it completely eliminates the risk of token theft via XSS.


9. Content Security Policy for Blazor WASM

CSP in Blazor WASM requires special attention compared to what we saw in article #12, because WASM has specific requirements:

// Blazor WASM requires 'wasm-unsafe-eval' for WASM execution
// and 'unsafe-inline' may be needed for Blazor internals
app.Use(async (context, next) =>
{
    context.Response.Headers.Append(
        "Content-Security-Policy",
        string.Join("; ",
            "default-src 'self'",

            // WASM requires this flag
            "script-src 'self' 'wasm-unsafe-eval'",

            "style-src 'self' 'unsafe-inline'",  // Blazor CSS
            "img-src 'self' data:",
            "connect-src 'self' https://your-idp.com https://api.myapp.com",
            "frame-src 'self' https://your-idp.com",  // OIDC silent refresh
            "frame-ancestors 'none'"
        ));
    await next();
});

Add connect-src with all domains your app fetches from — Identity Provider, API backends, CDNs. If missing, requests will be blocked by the browser without visible error in the UI.


10. Common problems and their solutions

Problem Probable cause Solution
AccessTokenNotAvailableException Expired token or lost session Catch the exception and call ex.Redirect() for re-authentication
Redirect loop at login Callback route not defined or Authentication.razor missing Check that the page /authentication/{action} exists and RedirectUri in config matches what is registered at IdP
Token not sent to API BaseAddressAuthorizationMessageHandler not added on HttpClient Check registration in Program.cs; for external domains use AuthorizationMessageHandler
Claims missing in component GetClaimsFromUserInfoEndpoint not enabled or claims not mapped Add options.ProviderOptions.AdditionalProviderParameters.Add("claims", ...); or configure claims mapping on IdP
CORS error on API call API does not accept the Origin of the Blazor app Configure CORS on API with the exact origin of the Blazor app; do not use wildcard in production
Protected page visible before redirect AuthorizeRouteView lacks <Authorizing> configured Add <Authorizing> template with loading spinner — prevents flash of unauthorized content

11. Production checklist

  • ✅ Zero secrets in WASM code or in wwwroot/appsettings.json
  • ✅ All authorization logic replicated on API — WASM only handles UX
  • ✅ Tokens stored in memory, not in localStorage
  • BaseAddressAuthorizationMessageHandler on HttpClients to API
  • AccessTokenNotAvailableException caught and redirected correctly in all components with API calls
  • ✅ CSP configured with wasm-unsafe-eval and explicit connect-src for all domains
  • ✅ CORS on API restricted to the exact origin of the Blazor app
  • ✅ API checks resource ownership (not just authentication) for CRUD operations
  • <Authorizing> template configured — prevents content flash before authentication check
  • ✅ Consider BFF pattern for applications with high security requirements

Conclusion

Blazor WebAssembly is an excellent platform for modern web applications, but it comes with a fundamentally different security model compared to server-side applications. The basic principle does not change regardless of technology: do not trust the client.

Blazor WASM makes visual authentication and authorization convenient through [Authorize] and AuthorizeView. But these are UX tools, not security guards. The real guards are on the server — the API that validates every request independently, without assuming Blazor has already checked anything.

The series continues with Rate Limiting in .NET — an essential mechanism to protect APIs from abuse, regardless of the front runner used.