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.jsonis 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]andAuthorizeViewin 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 - ✅
BaseAddressAuthorizationMessageHandleron HttpClients to API - ✅
AccessTokenNotAvailableExceptioncaught and redirected correctly in all components with API calls - ✅ CSP configured with
wasm-unsafe-evaland explicitconnect-srcfor 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.