Auth in Blazor
Key Points
AuthenticationStateProvideris the central abstraction. Provides currentClaimsPrincipal. Different impls per render mode.- Interactive Server: cookie auth typically. Auth happens server-side; principal flows to component via
AuthenticationStateProvider. - Interactive WASM: token-based (PKCE OIDC).
AuthenticationStateProviderreads from local store / cookies via BFF. - Auto mode: state must transfer from server prerender to WASM (use
PersistentComponentState). <AuthorizeView>+[Authorize]for declarative gating.<AuthorizeRouteView>at router for page-level.
Concepts (deep dive)
Setup (Interactive Server / Static SSR)
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie();
builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
app.UseAuthentication();
app.UseAuthorization();
@inherits LayoutComponentBase
<AuthorizeView>
<Authorized>Hello, @context.User.Identity?.Name!</Authorized>
<NotAuthorized><a href="/login">Login</a></NotAuthorized>
</AuthorizeView>
Setup (Interactive WASM)
For standalone WASM with OIDC:
// Client/Program.cs
builder.Services.AddOidcAuthentication(o =>
{
builder.Configuration.Bind("Auth", o.ProviderOptions);
o.ProviderOptions.DefaultScopes.Add("openid");
o.ProviderOptions.DefaultScopes.Add("api.read");
});
"Auth": {
"Authority": "https://login.microsoftonline.com/{tenant}/v2.0",
"ClientId": "...",
"RedirectUri": "https://localhost:5001/authentication/login-callback",
"PostLogoutRedirectUri": "https://localhost:5001/authentication/logout-callback",
"ResponseType": "code"
}
<AuthorizeView>
<AuthorizeView Roles="Admin">
<Authorized>You are admin</Authorized>
<NotAuthorized>Not authorized</NotAuthorized>
<Authorizing>Loading...</Authorizing>
</AuthorizeView>
<AuthorizeView Policy="MinimumAge">
<Authorized>...</Authorized>
</AuthorizeView>
Per-page authorization
In App.razor / Routes.razor:
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData">
<NotAuthorized>
<p>Not authorized.</p>
</NotAuthorized>
</AuthorizeRouteView>
</Found>
</Router>
AuthenticationStateProvider
@inject AuthenticationStateProvider Auth
@code {
string? _user;
protected override async Task OnInitializedAsync()
{
var state = await Auth.GetAuthenticationStateAsync();
_user = state.User.Identity?.Name;
}
}
Or use the cascading parameter:
<CascadingAuthenticationState>
<App />
</CascadingAuthenticationState>
@* In any component: *@
[CascadingParameter] public Task<AuthenticationState>? AuthState { get; set; }
Token storage in WASM — Pitfalls
- localStorage: vulnerable to XSS. Tokens stolen via injected script.
- sessionStorage: same issue; lost on tab close.
- HttpOnly cookies: best — JS can't read.
- In-memory: lost on refresh; reasonable for short tasks.
For production WASM: use BFF (Backend-for-Frontend) pattern. SPA never holds the token; backend (cookie session) does. See JWT Validation Pitfalls.
BFF pattern with Blazor WASM
User signs in via OIDC against Blazor Host (cookie). Host calls API server-to-server with bearer. WASM never sees the token.
Auto mode auth
AuthenticationState must flow: - Server prerender: cookie auth. - WASM: needs same identity.
Use PersistentComponentState to transfer:
// PersistedAuthState provider
[Inject] PersistentComponentState State { get; set; } = default!;
// Persist on server side
State.RegisterOnPersisting(() =>
{
State.PersistAsJson("auth", currentUser);
return Task.CompletedTask;
});
// Restore on WASM side
if (State.TryTakeFromJson<UserInfo>("auth", out var u)) /* ... */;
The Blazor template scaffolds this for Auto mode.
Calling secured APIs
// WASM
public class ApiClient(HttpClient http, IAccessTokenProvider tokens)
{
public async Task<T> GetAsync<T>(string url)
{
var token = await tokens.RequestAccessToken();
if (token.TryGetToken(out var accessToken))
http.DefaultRequestHeaders.Authorization = new("Bearer", accessToken.Value);
return await http.GetFromJsonAsync<T>(url) ?? throw new();
}
}
Or AuthorizationMessageHandler:
builder.Services.AddHttpClient("api", c => c.BaseAddress = new("https://api/"))
.AddHttpMessageHandler<AuthorizationMessageHandler>();
Auto-attaches token.
Logout
Server side:
WASM:
The OIDC handler navigates to IdP logout, clears cookie, redirects back.
Authorization policies
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("MinimumAge", p => p.RequireClaim("age", "18", "19", /* ... */));
});
See Authorization Policies & Claims.
Identity-relevant lifecycle
- User logs in → cookie set / token stored → AuthenticationStateProvider notified → components re-render.
- User logs out → state cleared → re-render.
- Token expires → silent refresh or redirect to login.
Force re-render on auth change
public class CustomAuthProvider : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() { /* ... */ }
public void NotifyChanged()
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
Common pitfalls
- Storing tokens in localStorage — XSS.
- Mixing auth schemes between server and WASM in Auto mode.
- No PersistentComponentState — re-auth roundtrip in Auto.
- AuthorizeView Authorizing slot missing — flash of "not authorized".
Code: correct vs wrong
❌ Wrong: token in localStorage
✅ Correct: BFF / HttpOnly cookie
WASM sends with credentials; backend reads cookie.
❌ Wrong: page-level [Authorize] without router config
Without <AuthorizeRouteView>, attribute ignored at routing. Add to App.razor.
✅ Correct: AuthorizeRouteView in router
Design patterns for this topic
Pattern 1 — "BFF over SPA-direct OAuth"
- Intent: SPA never holds tokens.
Pattern 2 — "AuthorizeView for declarative gating"
- Intent: clean component-level UI hiding.
Pattern 3 — "PersistentComponentState for Auto"
- Intent: seamless server→WASM transition.
Pattern 4 — "AuthorizationMessageHandler for outbound auth"
- Intent: auto-attach token to API calls.
Pattern 5 — "Authorization policies"
- Intent: centralized rules.
Pros & cons / trade-offs
| Pattern | Pros | Cons |
|---|---|---|
| BFF | Secure | More moving parts |
| Cookie auth (Server) | Simple | Server state |
| OIDC + WASM (direct) | Standalone | Token-in-browser risks |
| Auto mode | Best UX | State transfer complexity |
When to use / when to avoid
- Use BFF for SPA + secure tokens.
- Use cookie auth for Interactive Server / Static SSR.
- Use OIDC + WASM for offline-capable PWA.
- Avoid localStorage tokens.
Interview Q&A
Q1. AuthenticationStateProvider purpose? Central abstraction for current ClaimsPrincipal. Per-render-mode implementations.
Q2. Cookie vs token in Blazor? Cookie: server (Interactive Server, Static SSR). Token: WASM standalone.
Q3. BFF pattern? Backend holds tokens; SPA uses cookie session to backend. SPA never sees the token.
Q4. AuthorizeView vs [Authorize]? View: in-component declarative. Attribute: route/component-level via AuthorizeRouteView.
Q5. localStorage for tokens — risk? XSS reads it. HttpOnly cookies preferred.
Q6. Auto mode auth state? Persisted via PersistentComponentState from server prerender to WASM.
Q7. AuthorizationMessageHandler? Auto-attaches token to outbound HTTP calls.
Q8. Logout flow? SignOutAsync (server) or navigate to authentication/logout (WASM OIDC).
Q9. Policies in Blazor? Same as ASP.NET — AddAuthorization with named policies.
Q10. NotifyAuthenticationStateChanged? Force re-render when auth state changes (login/logout).
Q11. Authorizing slot in AuthorizeView? Show during auth check; avoids flash of NotAuthorized.
Q12. Multi-tenant Blazor auth? OIDC with dynamic authority per tenant; or claim-based tenant.
Gotchas / common mistakes
- ⚠️ Tokens in localStorage.
- ⚠️ No AuthorizeRouteView — page-level attributes ignored.
- ⚠️ Auto mode without state transfer — re-auth.
- ⚠️ Mixing schemes confusedly in Auto.