· Lincoln J Bicalho · Development · 24 min read
JWT Authentication in Blazor 8: Production Implementation Guide
Complete reference guide for implementing JWT authentication across Blazor 8 render modes. Includes quick-start code, parameter tables, configuration matrices, and troubleshooting solutions for production deployments.
📋 Prerequisites:
- .NET 8 SDK or later
- Understanding of JWT tokens and OAuth 2.0 flows
- Familiarity with Blazor render modes (SSR, Server, WASM, Auto)
- Basic knowledge of dependency injection
TL;DR - Quick Start
For the most common scenario (hybrid Blazor app with cookie + localStorage), here’s the minimal implementation:
// Program.cs - Add these services
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(30);
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IAuthTokenService, HybridAuthTokenService>();
builder.Services.AddScoped<CustomAuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(provider =>
provider.GetRequiredService<CustomAuthStateProvider>());
// Configure middleware - ORDER MATTERS
app.UseSession();
app.UseAuthentication();
app.UseAuthorization();💡 Tip: This configuration works for 80% of Blazor authentication scenarios. Skip to Advanced Scenarios if you need multi-tenant support or custom token validation.
Overview
JWT authentication in Blazor 8 requires handling multiple render modes with different execution contexts. This guide provides a reference implementation that works across SSR (Static Server-Side Rendering), Interactive Server, WebAssembly, and Auto render modes.
What this guide covers:
- Hybrid token storage (session, cookies, localStorage)
- Automatic token refresh before expiration
- Prerendering-safe JavaScript interop
- Production-ready error handling
- Cross-render-mode authentication state
What you’ll build: A token service that automatically adapts to the current rendering context, stores tokens in the appropriate location, and maintains authentication state across navigation and render mode transitions.
ℹ️ Note: This implementation uses built-in .NET JWT parsing—no external libraries required.
🎯 Companion Demo Repository
Looking for a simpler starting point? Check out the BlazorJwtAuthDemo repository that demonstrates JWT authentication with a Blazor Server app and separate API:
🔗 View Demo on GitHub: BlazorJwtAuthDemo
What’s included:
- Production-ready Blazor Server + Web API implementation
- Cookie + JWT pattern (simpler than full hybrid storage)
- Complete authentication flow with mock data
- Login, Register, Dashboard, and Logout pages
- Comprehensive documentation (ARCHITECTURE.md, TROUBLESHOOTING.md)
- Cross-platform run scripts - start both projects with one command
- Based on production patterns from 10+ enterprise systems
When to use the demo vs this guide:
| Use the Demo Repository | Use This Full Guide |
|---|---|
| Learning JWT authentication basics | Building hybrid render mode apps |
| Standard Blazor Server apps | Apps using SSR + Server + WASM |
| Separate API architecture | Need comprehensive prerendering support |
| Interactive Server mode only | Using Auto render mode |
💡 Tip: The demo repository is perfect for understanding the fundamentals and getting a working implementation running quickly. This guide provides the advanced hybrid storage approach needed when you’re working with multiple render modes and complex prerendering scenarios.
JWT Configuration Parameters
Token Service Configuration
| Parameter | Type | Default | Required | Description |
|---|---|---|---|---|
AccessTokenKey | string | "auth_token" | Yes | Storage key for access token |
RefreshTokenKey | string | "refresh_token" | Yes | Storage key for refresh token |
TokenExpiryKey | string | "token_expiry" | No | Storage key for expiration timestamp |
CacheExpiry | TimeSpan | 5 minutes | No | In-memory token cache duration |
RefreshBuffer | TimeSpan | 1 minute | No | Time before expiry to trigger refresh |
Cookie Configuration Options
| Option | Type | Recommended Value | Security Impact |
|---|---|---|---|
HttpOnly | bool | true | Critical - Prevents JavaScript access, protects against XSS |
Secure | bool | true | Critical - Requires HTTPS, prevents interception |
SameSite | SameSiteMode | Strict | High - Prevents CSRF attacks |
Expires | DateTimeOffset | 7 days (access), 30 days (refresh) | Medium - Balances security and UX |
Session Configuration Options
| Option | Type | Recommended Value | Description |
|---|---|---|---|
IdleTimeout | TimeSpan | 30 minutes | How long before inactive session expires |
Cookie.Name | string | .YourApp.Session | Session cookie identifier |
Cookie.IsEssential | bool | true | Bypasses GDPR consent requirements |
⚠️ Warning: Setting
HttpOnlytofalsecreates a critical security vulnerability. Client-side JavaScript can access tokens, enabling XSS attacks to steal authentication credentials.
Configuration Matrix
Development vs. Production Settings
| Setting | Development | Production | Why Different? |
|---|---|---|---|
Cookie.Secure | false | true | Development uses HTTP, production requires HTTPS |
Cookie.SameSite | Lax | Strict | Development needs flexibility, production prioritizes security |
| Logging Level | Debug | Information | Development needs detailed traces, production reduces noise |
| Token Cache | Disabled | 5 minutes | Development needs immediate updates, production optimizes performance |
| Session Timeout | 60 minutes | 30 minutes | Development reduces interruptions, production reduces exposure |
Render Mode Storage Strategy
| Render Mode | Primary Storage | Fallback Storage | Cache Layer | Prerender Safe? |
|---|---|---|---|---|
| SSR | Session | Cookie | Memory | ✅ Yes |
| Server | Session + Cookie | N/A | Memory | ✅ Yes |
| WASM | LocalStorage | Cookie | Memory | ❌ No - use fallback |
| Auto | Hybrid (all layers) | Session | Memory | ⚠️ Conditional |
ℹ️ Note: The hybrid approach uses multiple storage layers and attempts them in sequence, ensuring tokens are available regardless of the current render mode.
💡 Tip: The TokenBridgeComponent (Step 4) ensures automatic token synchronization from server-side claims to localStorage across all render modes. It uses
@rendermode InteractiveAutoto work universally and is recommended for all implementations using hybrid storage.
Core Concepts
Understanding Hybrid Token Storage
Blazor 8’s render modes execute in different contexts:
SSR (Static Server Rendering)
- Executes on the server during initial request
- No JavaScript runtime available
- Can access
HttpContext.SessionandHttpContext.Request.Cookies - Storage strategy: Session + Cookie
Interactive Server
- Executes on server via SignalR connection
- Limited JavaScript interop (async only)
- Full access to server-side storage
- Storage strategy: Session + Cookie + ProtectedSessionStorage
Interactive WebAssembly
- Executes in browser on client
- Full JavaScript runtime available
- No access to server-side sessions
- Storage strategy: LocalStorage + Cookie
Auto Mode
- Initially renders as Server
- Optionally transitions to WebAssembly after download
- Requires handling both contexts
- Storage strategy: All layers with fallback logic
💡 Tip: The key to reliable authentication is checking which storage mechanisms are available at runtime and using the appropriate one for the current context.
The Prerendering Challenge
During server-side prerendering, your component executes twice:
- First pass (prerendering): Generates static HTML on server
- Second pass (interactive): Hydrates the component for interactivity
The problem: JavaScript interop calls fail during prerendering with this error:
System.InvalidOperationException:
JavaScript interop calls cannot be issued at this time.
This is because the component is being statically rendered.The solution: Detect prerendering and use server-side alternatives:
// ❌ PROBLEM: Crashes during prerendering
public async Task<string> GetToken()
{
return await JSRuntime.InvokeAsync<string>("localStorage.getItem", "token");
}
// ✅ SOLUTION: Check rendering context first
public async Task<string> GetToken()
{
if (IsPrerendering)
{
// Use server-side storage during prerender
return httpContext.Session.GetString("token");
}
// Safe to use JavaScript after prerendering
return await JSRuntime.InvokeAsync<string>("localStorage.getItem", "token");
}Token Validation and Expiration
JWT tokens contain an expiration claim (exp) as a Unix timestamp. You must validate this before using the token:
private bool IsTokenExpired(string token)
{
try
{
// JWT structure: header.payload.signature
var payload = token.Split('.')[1];
// Decode Base64URL (may need padding)
var json = Encoding.UTF8.GetString(ParseBase64WithoutPadding(payload));
var data = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
if (data != null && data.TryGetValue("exp", out var exp))
{
var expTime = DateTimeOffset.FromUnixTimeSeconds(exp.GetInt64());
// Add 1-minute buffer to handle clock skew and network latency
return expTime < DateTimeOffset.UtcNow.AddMinutes(1);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error parsing token expiration");
}
// Assume expired if we can't parse it
return true;
}⚠️ Warning: Always include a time buffer when checking token expiration. Network latency and clock skew between client and server can cause valid tokens to be rejected if you check for exact expiration.
Implementation Patterns
Step 1: Define the Token Service Interface
Create a contract that abstracts token storage across all render modes:
// FILE: IAuthTokenService.cs
// PURPOSE: Abstracts token storage for all Blazor render modes
namespace YourApp.Authentication;
public interface IAuthTokenService
{
/// <summary>
/// Retrieves the current access token from available storage.
/// Returns null if no token is found or token is expired.
/// </summary>
ValueTask<string?> GetTokenAsync();
/// <summary>
/// Retrieves the refresh token for token renewal.
/// Returns null if no refresh token is available.
/// </summary>
ValueTask<string?> GetRefreshTokenAsync();
/// <summary>
/// Stores both access and refresh tokens in all available storage layers.
/// Handles prerendering context and synchronizes across storage mechanisms.
/// </summary>
ValueTask SetTokensAsync(string? token, string? refreshToken);
/// <summary>
/// Removes tokens from all storage locations.
/// Safe to call during prerendering or any render mode.
/// </summary>
ValueTask ClearTokensAsync();
/// <summary>
/// Indicates whether the current request is in the prerendering phase.
/// Use this to avoid JavaScript interop during server-side rendering.
/// </summary>
bool IsPrerendering { get; }
/// <summary>
/// Attempts to refresh the access token using the refresh token.
/// Returns true if refresh succeeded, false otherwise.
/// </summary>
ValueTask<bool> TryRefreshTokenAsync();
}ℹ️ Note: Using
ValueTaskinstead ofTaskreduces allocations for frequently-called authentication checks. This provides measurable performance improvements in high-traffic applications.
Step 2: Implement Hybrid Token Storage
The implementation handles all render modes by attempting storage mechanisms in sequence:
// FILE: HybridAuthTokenService.cs
// PURPOSE: Production-ready token service with multi-layer storage
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using System.Text.Json;
namespace YourApp.Authentication;
public class HybridAuthTokenService : IAuthTokenService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IJSRuntime _jsRuntime;
private readonly ILogger<HybridAuthTokenService> _logger;
private readonly HttpClient _httpClient;
// Storage keys - customize these for your application
private const string AccessTokenKey = "auth_token";
private const string RefreshTokenKey = "refresh_token";
// In-memory cache for request lifetime optimization
private string? _cachedToken;
private string? _cachedRefreshToken;
private DateTime? _cacheExpiry;
public HybridAuthTokenService(
IHttpContextAccessor httpContextAccessor,
IJSRuntime jsRuntime,
ILogger<HybridAuthTokenService> logger,
IHttpClientFactory httpClientFactory)
{
_httpContextAccessor = httpContextAccessor;
_jsRuntime = jsRuntime;
_logger = logger;
_httpClient = httpClientFactory.CreateClient("AuthAPI");
}
public bool IsPrerendering
{
get
{
var context = _httpContextAccessor.HttpContext;
if (context == null) return false;
// WHY: Response.HasStarted is false during prerendering
// HOW: After prerendering, response has started streaming to client
return !context.Response.HasStarted;
}
}
public async ValueTask<string?> GetTokenAsync()
{
try
{
// OPTIMIZATION: Return cached token if still valid
if (!string.IsNullOrEmpty(_cachedToken) && _cacheExpiry > DateTime.UtcNow)
{
return _cachedToken;
}
var context = _httpContextAccessor.HttpContext;
string? token = null;
// LAYER 1: Server-side storage (works in all server-based modes)
if (context != null)
{
// Try session first (fastest access)
token = context.Session.GetString(AccessTokenKey);
// Fallback to cookies (persist across server restarts)
if (string.IsNullOrEmpty(token))
{
context.Request.Cookies.TryGetValue(AccessTokenKey, out token);
}
}
// LAYER 2: Client-side storage (required for WASM mode)
if (string.IsNullOrEmpty(token) && !IsPrerendering)
{
token = await TryGetFromLocalStorageAsync();
}
// VALIDATION: Check token hasn't expired
if (!string.IsNullOrEmpty(token) && IsTokenExpired(token))
{
_logger.LogDebug("Token expired, attempting automatic refresh");
if (await TryRefreshTokenAsync())
{
// Recursive call to get the newly refreshed token
return await GetTokenAsync();
}
// Clear expired token
await ClearTokensAsync();
return null;
}
// CACHE: Store valid token for subsequent requests
if (!string.IsNullOrEmpty(token))
{
_cachedToken = token;
_cacheExpiry = DateTime.UtcNow.AddMinutes(5);
}
return token;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving authentication token");
return null;
}
}
public async ValueTask SetTokensAsync(string? token, string? refreshToken)
{
try
{
var context = _httpContextAccessor.HttpContext;
// Clear cache to force re-evaluation
_cachedToken = null;
_cachedRefreshToken = null;
_cacheExpiry = null;
if (context != null)
{
// LAYER 1: Session storage (if response hasn't started)
if (!context.Response.HasStarted)
{
if (!string.IsNullOrEmpty(token))
{
context.Session.SetString(AccessTokenKey, token);
}
if (!string.IsNullOrEmpty(refreshToken))
{
context.Session.SetString(RefreshTokenKey, refreshToken);
}
}
// LAYER 2: Cookie storage (persists across server restarts)
var cookieOptions = new CookieOptions
{
HttpOnly = true, // Prevents JavaScript access
Secure = true, // Requires HTTPS
SameSite = SameSiteMode.Strict, // Prevents CSRF
Expires = DateTimeOffset.UtcNow.AddDays(7)
};
if (!string.IsNullOrEmpty(token))
{
context.Response.Cookies.Append(AccessTokenKey, token, cookieOptions);
}
if (!string.IsNullOrEmpty(refreshToken))
{
// Refresh tokens get longer expiry
var refreshCookieOptions = cookieOptions with
{
Expires = DateTimeOffset.UtcNow.AddDays(30)
};
context.Response.Cookies.Append(RefreshTokenKey, refreshToken, refreshCookieOptions);
}
}
// LAYER 3: LocalStorage for WASM scenarios
if (!IsPrerendering)
{
await TrySetLocalStorageAsync(token, refreshToken);
}
_logger.LogDebug("Tokens successfully stored across all available layers");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error storing authentication tokens");
throw;
}
}
public async ValueTask ClearTokensAsync()
{
try
{
// Clear in-memory cache
_cachedToken = null;
_cachedRefreshToken = null;
_cacheExpiry = null;
var context = _httpContextAccessor.HttpContext;
if (context != null)
{
// Clear session storage
if (!context.Response.HasStarted)
{
context.Session.Remove(AccessTokenKey);
context.Session.Remove(RefreshTokenKey);
}
// Clear cookies by setting expired date
var expiredCookieOptions = new CookieOptions
{
Expires = DateTimeOffset.UtcNow.AddDays(-1)
};
context.Response.Cookies.Append(AccessTokenKey, "", expiredCookieOptions);
context.Response.Cookies.Append(RefreshTokenKey, "", expiredCookieOptions);
}
// Clear localStorage
if (!IsPrerendering)
{
await TryClearLocalStorageAsync();
}
_logger.LogDebug("All authentication tokens cleared successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error clearing authentication tokens");
}
}
public async ValueTask<bool> TryRefreshTokenAsync()
{
try
{
var refreshToken = await GetRefreshTokenAsync();
if (string.IsNullOrEmpty(refreshToken))
{
_logger.LogDebug("No refresh token available for renewal");
return false;
}
// Call your API endpoint to refresh the token
var response = await _httpClient.PostAsJsonAsync("/api/auth/refresh",
new { refreshToken });
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<TokenResponse>();
if (result != null)
{
await SetTokensAsync(result.AccessToken, result.RefreshToken);
_logger.LogInformation("Token refreshed successfully");
return true;
}
}
else
{
_logger.LogWarning("Token refresh failed with status: {Status}",
response.StatusCode);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing authentication token");
}
return false;
}
public async ValueTask<string?> GetRefreshTokenAsync()
{
try
{
if (!string.IsNullOrEmpty(_cachedRefreshToken))
{
return _cachedRefreshToken;
}
var context = _httpContextAccessor.HttpContext;
string? refreshToken = null;
if (context != null)
{
refreshToken = context.Session.GetString(RefreshTokenKey);
if (string.IsNullOrEmpty(refreshToken))
{
context.Request.Cookies.TryGetValue(RefreshTokenKey, out refreshToken);
}
}
if (string.IsNullOrEmpty(refreshToken) && !IsPrerendering)
{
refreshToken = await TryGetFromLocalStorageAsync(RefreshTokenKey);
}
_cachedRefreshToken = refreshToken;
return refreshToken;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving refresh token");
return null;
}
}
// PRIVATE HELPER METHODS
private async Task<string?> TryGetFromLocalStorageAsync(string key = AccessTokenKey)
{
try
{
// WHY: Check if JS runtime is available and supports synchronous calls
// HOW: WASM supports IJSInProcessRuntime, Server components don't
if (_jsRuntime is IJSInProcessRuntime)
{
return await _jsRuntime.InvokeAsync<string?>(
"localStorage.getItem", key);
}
}
catch (InvalidOperationException)
{
// Expected during prerendering - not an error
}
catch (JSException jsEx)
{
_logger.LogDebug("JavaScript error accessing localStorage: {Message}",
jsEx.Message);
}
return null;
}
private async Task TrySetLocalStorageAsync(string? token, string? refreshToken)
{
try
{
if (_jsRuntime is IJSInProcessRuntime)
{
if (!string.IsNullOrEmpty(token))
{
await _jsRuntime.InvokeVoidAsync(
"localStorage.setItem", AccessTokenKey, token);
}
if (!string.IsNullOrEmpty(refreshToken))
{
await _jsRuntime.InvokeVoidAsync(
"localStorage.setItem", RefreshTokenKey, refreshToken);
}
}
}
catch (InvalidOperationException)
{
// Expected during prerendering
}
catch (JSException jsEx)
{
_logger.LogDebug("JavaScript error setting localStorage: {Message}",
jsEx.Message);
}
}
private async Task TryClearLocalStorageAsync()
{
try
{
if (_jsRuntime is IJSInProcessRuntime)
{
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", AccessTokenKey);
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", RefreshTokenKey);
}
}
catch (InvalidOperationException)
{
// Expected during prerendering
}
catch (JSException jsEx)
{
_logger.LogDebug("JavaScript error clearing localStorage: {Message}",
jsEx.Message);
}
}
private bool IsTokenExpired(string token)
{
try
{
var payload = token.Split('.')[1];
var json = Encoding.UTF8.GetString(ParseBase64WithoutPadding(payload));
var data = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
if (data != null && data.TryGetValue("exp", out var exp))
{
var expTime = DateTimeOffset.FromUnixTimeSeconds(exp.GetInt64());
// WHY: 1-minute buffer handles clock skew and network latency
return expTime < DateTimeOffset.UtcNow.AddMinutes(1);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error parsing token expiration");
}
// Assume expired if we can't parse it
return true;
}
private byte[] ParseBase64WithoutPadding(string base64)
{
// WHY: JWT uses Base64URL encoding which omits padding
// HOW: Add padding based on length to decode properly
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
}
// Supporting types
public class TokenResponse
{
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
public int ExpiresIn { get; set; }
}💡 Tip: The in-memory cache reduces redundant token retrievals. In production testing with enterprise systems, this optimization significantly reduced authentication overhead under high load.
Step 3: Create Authentication State Provider
Integrate the token service with Blazor’s built-in authorization system:
// FILE: CustomAuthStateProvider.cs
// PURPOSE: Bridges token service with Blazor authentication
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;
using System.Text.Json;
namespace YourApp.Authentication;
public class CustomAuthStateProvider : AuthenticationStateProvider
{
private readonly IAuthTokenService _tokenService;
private readonly ILogger<CustomAuthStateProvider> _logger;
// Cache authentication state to avoid repeated token parsing
private AuthenticationState? _cachedAuthState;
private DateTime _cacheExpiry = DateTime.MinValue;
public CustomAuthStateProvider(
IAuthTokenService tokenService,
ILogger<CustomAuthStateProvider> logger)
{
_tokenService = tokenService;
_logger = logger;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
// Return cached state if still valid
if (_cachedAuthState != null && _cacheExpiry > DateTime.UtcNow)
{
return _cachedAuthState;
}
var token = await _tokenService.GetTokenAsync();
if (string.IsNullOrEmpty(token))
{
_logger.LogDebug("No token found, returning anonymous user");
return CreateAnonymousState();
}
var claims = ParseClaimsFromJwt(token);
// Validate token expiration from claims
var expClaim = claims.FirstOrDefault(c => c.Type == "exp");
if (expClaim != null)
{
var expTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(expClaim.Value));
if (expTime < DateTimeOffset.UtcNow)
{
_logger.LogDebug("Token expired, clearing and returning anonymous");
await _tokenService.ClearTokensAsync();
return CreateAnonymousState();
}
// Proactive refresh if expiring soon
if (expTime < DateTimeOffset.UtcNow.AddMinutes(5))
{
_logger.LogDebug("Token expiring soon, attempting refresh");
_ = Task.Run(async () => await _tokenService.TryRefreshTokenAsync());
}
}
var identity = new ClaimsIdentity(claims, "jwt");
var user = new ClaimsPrincipal(identity);
// Cache for 1 minute to reduce parsing overhead
_cachedAuthState = new AuthenticationState(user);
_cacheExpiry = DateTime.UtcNow.AddMinutes(1);
_logger.LogDebug("User authenticated: {UserName}",
user.Identity?.Name ?? "Unknown");
return _cachedAuthState;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting authentication state");
return CreateAnonymousState();
}
}
public async Task LoginAsync(string token, string refreshToken)
{
try
{
await _tokenService.SetTokensAsync(token, refreshToken);
// Clear cache to force re-evaluation
_cachedAuthState = null;
_cacheExpiry = DateTime.MinValue;
// WHY: Notify all components that authentication state changed
// HOW: Triggers re-rendering of AuthorizeView and authentication checks
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
_logger.LogInformation("User logged in successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during login");
throw;
}
}
public async Task LogoutAsync()
{
try
{
await _tokenService.ClearTokensAsync();
_cachedAuthState = null;
_cacheExpiry = DateTime.MinValue;
NotifyAuthenticationStateChanged(Task.FromResult(CreateAnonymousState()));
_logger.LogInformation("User logged out successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during logout");
throw;
}
}
private AuthenticationState CreateAnonymousState()
{
var anonymous = new ClaimsPrincipal(new ClaimsIdentity());
return new AuthenticationState(anonymous);
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
try
{
var payload = jwt.Split('.')[1];
var jsonBytes = ParseBase64WithoutPadding(payload);
var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
if (keyValuePairs == null)
{
return Enumerable.Empty<Claim>();
}
var claims = new List<Claim>();
foreach (var kvp in keyValuePairs)
{
if (kvp.Value is JsonElement element)
{
if (element.ValueKind == JsonValueKind.Array)
{
// Handle array claims (e.g., roles)
foreach (var item in element.EnumerateArray())
{
claims.Add(new Claim(kvp.Key, item.GetString() ?? ""));
}
}
else
{
claims.Add(new Claim(kvp.Key, element.ToString()));
}
}
else
{
claims.Add(new Claim(kvp.Key, kvp.Value?.ToString() ?? ""));
}
}
// Map JWT standard claims to .NET claim types
MapStandardClaims(claims);
return claims;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error parsing JWT claims");
return Enumerable.Empty<Claim>();
}
}
private void MapStandardClaims(List<Claim> claims)
{
// WHY: Map JWT standard claims to .NET Framework claim types
// HOW: This enables standard .NET authorization to work with JWT claims
var mappings = new Dictionary<string, string>
{
{ "sub", ClaimTypes.NameIdentifier },
{ "name", ClaimTypes.Name },
{ "email", ClaimTypes.Email },
{ "role", ClaimTypes.Role },
{ "roles", ClaimTypes.Role }
};
foreach (var mapping in mappings)
{
var claim = claims.FirstOrDefault(c => c.Type == mapping.Key);
if (claim != null)
{
claims.Add(new Claim(mapping.Value, claim.Value));
}
}
}
private byte[] ParseBase64WithoutPadding(string base64)
{
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
}ℹ️ Note: Proactive token refresh (5 minutes before expiry) prevents authentication failures during active user sessions. This pattern is common in enterprise applications with long-running operations.
Step 4: Implement Token Bridge Component
The Token Bridge component ensures tokens stored server-side (in claims/session) are automatically transferred to localStorage when JavaScript becomes available. This is essential for hybrid storage strategies.
Why you need this:
- Server-side login stores tokens in session/claims (JavaScript not available during request processing)
- Client-side components (especially WebAssembly) need tokens in localStorage
- The bridge automatically synchronizes tokens when the component renders client-side
How it works:
- Uses
@rendermode InteractiveAutoto run on the client after hydration - Extracts JWT from
ClaimsPrincipal(stored during server-side login) - Transfers token to localStorage via JavaScript interop
- Runs once on first render, then does nothing
// FILE: Components/TokenBridgeComponent.razor
// PURPOSE: Universal token synchronization across all render modes
@namespace YourApp.Components
@using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IJSRuntime JSRuntime
@inject ILogger<TokenBridgeComponent> Logger
@rendermode InteractiveAuto
@code {
/// <summary>
/// Token Bridge Component for Blazor 8 Hybrid Storage
///
/// PURPOSE: Transfer JWT tokens from server-side storage (claims/session/cookies)
/// to client-side localStorage for WebAssembly and client components
///
/// WHY: Server-side login cannot access JavaScript/localStorage,
/// so tokens are stored in ClaimsPrincipal. Client components
/// need tokens in localStorage. This component bridges that gap.
///
/// HOW: Runs OnAfterRenderAsync (when JavaScript is available) and transfers
/// tokens from ClaimsPrincipal claims to localStorage
///
/// WORKS WITH: All render modes (Server, WASM, Auto, SSR with interactivity)
/// </summary>
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// WHY: Only run once on first render
// HOW: firstRender is true only the first time component renders
if (!firstRender)
return;
try
{
// STEP 1: Get current authentication state
// WHY: Need to access user's claims to extract JWT
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
// STEP 2: Check if user is authenticated
// WHY: No point transferring token if user isn't logged in
if (!user.Identity?.IsAuthenticated ?? true)
{
Logger.LogDebug("Token bridge: User not authenticated, skipping transfer");
return;
}
// STEP 3: Extract JWT token from claims
// WHY: During server-side login, AuthService stores JWT as "jwt" claim
// HOW: Server-side components have access to ClaimsPrincipal
// NOTE: Claim name must match what your login process uses
var jwtClaim = user.FindFirst("jwt");
if (jwtClaim == null || string.IsNullOrEmpty(jwtClaim.Value))
{
Logger.LogDebug("Token bridge: No JWT claim found, token may already be in localStorage");
return;
}
var token = jwtClaim.Value;
// STEP 4: Store token in localStorage for client-side components
// WHY: HybridAuthTokenService reads from localStorage when server storage unavailable
// HOW: JSRuntime.InvokeVoidAsync is safe in OnAfterRenderAsync
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "auth_token", token);
Logger.LogInformation(
"Token bridge: Successfully transferred JWT to localStorage for user {UserName}",
user.Identity.Name ?? "Unknown");
}
catch (Exception ex)
{
// WHY: Log errors but don't throw - token may already be in localStorage
// HOW: This is a best-effort operation
Logger.LogError(ex, "Token bridge: Failed to transfer JWT to localStorage");
}
}
}Integration with MainLayout:
Add the Token Bridge component to your main layout so it runs on every page:
// FILE: Components/Layout/MainLayout.razor
// PURPOSE: Main application layout
@inherits LayoutComponentBase
@* Universal token synchronization - runs once per session *@
<TokenBridgeComponent />
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>💡 Tip: The Token Bridge component uses
@rendermode InteractiveAuto, which means it automatically runs on the client (Server or WebAssembly) where JavaScript is available. This makes it work universally across all Blazor 8 render modes.
When is the Token Bridge critical:
- Apps using the
HybridAuthTokenService(multi-layer storage strategy) - Server-side login with any client-side interactive components
- InteractiveAuto or mixed render mode applications
- Any scenario where authentication starts server-side but needs client-side token access
When you can skip it:
- Pure WebAssembly apps where login happens entirely client-side
- Server-only apps with no client-side interactivity
- Apps using only cookie-based authentication (no JWT tokens)
⚠️ Warning: If you implement the Token Bridge, ensure your login process stores the JWT as a claim in the
ClaimsPrincipal. The claim name must match what the Token Bridge looks for (typically “jwt”).
Step 5: Configure Services in Program.cs
Register all authentication services with proper lifetime scopes:
// FILE: Program.cs
// PURPOSE: Configure authentication pipeline
using Microsoft.AspNetCore.Components.Authorization;
using YourApp.Authentication;
var builder = WebApplication.CreateBuilder(args);
// CRITICAL: Session must be configured before authentication services
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(30);
options.Cookie.Name = ".YourApp.Session";
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
// Required for server-side token access
builder.Services.AddHttpContextAccessor();
// Configure HTTP client for authentication API
builder.Services.AddHttpClient("AuthAPI", client =>
{
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "https://api.yourapp.com");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
// Register authentication services
// WHY: Scoped lifetime ensures tokens are cached per-request/circuit
builder.Services.AddScoped<IAuthTokenService, HybridAuthTokenService>();
builder.Services.AddScoped<CustomAuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(provider =>
provider.GetRequiredService<CustomAuthStateProvider>());
// Configure authorization policies
builder.Services.AddAuthorizationCore(options =>
{
options.AddPolicy("RequireAuthenticated", policy =>
policy.RequireAuthenticatedUser());
options.AddPolicy("RequireAdmin", policy =>
policy.RequireRole("Admin"));
options.AddPolicy("RequireEmployee", policy =>
policy.RequireRole("Employee", "Admin"));
});
// Add Blazor components
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
var app = builder.Build();
// Configure HTTP pipeline
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
// CRITICAL: Middleware order determines security behavior
// WHY: Each middleware depends on the previous one being executed first
app.UseSession(); // Must precede authentication
app.UseAuthentication(); // Must precede authorization
app.UseAuthorization(); // Must precede endpoints
app.UseAntiforgery(); // Must precede Blazor components
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(YourApp.Client._Imports).Assembly);
app.Run();⚠️ Warning: Middleware order is critical. If you place
UseAuthorization()beforeUseAuthentication(), authorization checks will always fail because the user identity hasn’t been established yet.
Advanced Scenarios
Scenario 1: Custom Token Validation
For applications requiring additional token validation beyond expiration:
public class EnhancedAuthTokenService : HybridAuthTokenService
{
private readonly ITokenValidator _customValidator;
public override async ValueTask<string?> GetTokenAsync()
{
var token = await base.GetTokenAsync();
if (!string.IsNullOrEmpty(token))
{
// Custom validation logic
if (!await _customValidator.ValidateAsync(token))
{
logger.LogWarning("Token failed custom validation");
await ClearTokensAsync();
return null;
}
}
return token;
}
}Use this when:
- You need tenant-specific validation
- Tokens require additional signature verification
- Business rules determine token validity
Scenario 2: Multi-Tenant Token Context
For applications serving multiple tenants with tenant-specific tokens:
public async ValueTask<string?> GetTokenAsync(string tenantId)
{
// Modify storage keys to include tenant context
var tenantKey = $"{AccessTokenKey}_{tenantId}";
// Rest of implementation uses tenant-specific keys
var token = context.Session.GetString(tenantKey);
// Validate token contains correct tenant claim
var claims = ParseClaimsFromJwt(token);
var tokenTenantId = claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value;
if (tokenTenantId != tenantId)
{
logger.LogWarning("Token tenant mismatch: expected {Expected}, got {Actual}",
tenantId, tokenTenantId);
return null;
}
return token;
}⚠️ Warning: Always validate the tenant claim in multi-tenant systems. Failure to verify tenant context creates a critical security vulnerability where users could access data from other tenants.
Scenario 3: Automatic Background Token Refresh
For long-running sessions that need proactive token renewal:
public class BackgroundTokenRefreshService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<BackgroundTokenRefreshService> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _serviceProvider.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<IAuthTokenService>();
// Attempt refresh every 10 minutes
await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken);
await tokenService.TryRefreshTokenAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Background token refresh failed");
}
}
}
}Register the service:
builder.Services.AddHostedService<BackgroundTokenRefreshService>();Troubleshooting Quick Fixes
| Error Message | Root Cause | Solution |
|---|---|---|
JavaScript interop calls cannot be issued at this time | Calling JS during prerendering | Check IsPrerendering before JS calls, use server-side fallback |
Cannot access a disposed object | Attempting to write to response after it started | Check !context.Response.HasStarted before session writes |
Session has not been configured for this application | Missing session middleware | Add builder.Services.AddSession() and app.UseSession() |
| Token not persisting across requests | Middleware order incorrect | Ensure UseSession() comes before authentication middleware |
| Authentication state not updating | Missing state change notification | Call NotifyAuthenticationStateChanged() after login/logout |
| Cookies not being set | HTTPS required but using HTTP | Set Cookie.Secure = false in development or use HTTPS |
| Token refresh fails silently | HttpClient not configured | Verify HttpClient base address and ensure API endpoint exists |
| Claims not mapping to authorization | Claim types don’t match .NET types | Use MapStandardClaims() to convert JWT claims to ClaimTypes |
| Token not available in localStorage | TokenBridge not implemented or JWT claim missing | Implement TokenBridgeComponent and ensure login stores JWT as “jwt” claim |
💡 Tip: Enable detailed logging during development to see exactly where authentication fails:
builder.Logging.AddFilter("YourApp.Authentication", LogLevel.Debug);Production Deployment Checklist
Security Configuration
// Enforce HTTPS in production
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
app.UseHttpsRedirection();
}
// Add security headers
app.Use(async (context, next) =>
{
context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
context.Response.Headers.Add("X-Frame-Options", "DENY");
context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
context.Response.Headers.Add("Referrer-Policy", "strict-origin-when-cross-origin");
await next();
});Pre-Deployment Validation
- Cookie
Secureflag set totrue - Cookie
HttpOnlyflag set totrue - Session timeout configured appropriately (15-30 minutes recommended)
- Token expiration aligned with business requirements
- Refresh token rotation implemented
- TokenBridgeComponent implemented and added to MainLayout
- Login process stores JWT as “jwt” claim in ClaimsPrincipal
- HTTPS enforced across all environments
- Security headers configured
- Logging configured (Info level or higher)
- Error handling tested for all failure scenarios
- Token refresh tested under load
- Cross-render-mode transitions tested
- Prerendering scenarios validated
Token Expiration Best Practices
| Token Type | Recommended Expiration | Rationale |
|---|---|---|
| Access Token | 15-30 minutes | Limits exposure if compromised |
| Refresh Token | 7-30 days | Balances security with user experience |
| Session | 30-60 minutes idle | Protects inactive sessions |
| Remember Me | 30-90 days | Extended convenience, must be explicit opt-in |
⚠️ Warning: Access tokens longer than 60 minutes increase security risk. If compromised, an attacker has extended access to your system. Use short-lived access tokens with automatic refresh instead.
Performance Monitoring
Track these metrics in production:
// Add Application Insights or similar
builder.Services.AddApplicationInsightsTelemetry();
// Custom metrics for authentication
public class AuthenticationMetrics
{
private readonly ILogger _logger;
public void RecordTokenRefresh(bool success, TimeSpan duration)
{
_logger.LogMetric("TokenRefresh", success ? 1 : 0, new Dictionary<string, object>
{
{ "Success", success },
{ "DurationMs", duration.TotalMilliseconds }
});
}
public void RecordAuthenticationFailure(string reason)
{
_logger.LogMetric("AuthenticationFailure", 1, new Dictionary<string, object>
{
{ "Reason", reason }
});
}
}Key metrics to track:
- Token refresh success rate (target: >99%)
- Authentication failure rate (investigate if >5%)
- Average token retrieval time (target: <50ms)
- Session persistence rate
- JavaScript interop error rate (target: 0%)
Next Steps
You now have a production-ready JWT authentication system for Blazor 8. Here’s what to implement next:
- Implement Login Component - Create UI for user authentication (see example below)
- Add Authorization Policies - Define role-based or claims-based policies
- Configure Token Refresh API - Implement server endpoint for token renewal
- Test All Render Modes - Validate SSR, Server, WASM, and Auto modes
- Enable Monitoring - Set up Application Insights or similar telemetry
- Security Audit - Review configuration against OWASP guidelines
Example Login Component
@page "/login"
@inject CustomAuthStateProvider AuthStateProvider
@inject NavigationManager Navigation
<EditForm Model="@loginModel" OnValidSubmit="@HandleLogin">
<DataAnnotationsValidator />
<div class="mb-3">
<label for="email">Email</label>
<InputText id="email" class="form-control" @bind-Value="loginModel.Email" />
</div>
<div class="mb-3">
<label for="password">Password</label>
<InputText id="password" type="password" class="form-control"
@bind-Value="loginModel.Password" />
</div>
<button type="submit" class="btn btn-primary" disabled="@isLoading">
@if (isLoading) { <span class="spinner-border spinner-border-sm"></span> }
Login
</button>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger mt-3">@errorMessage</div>
}
</EditForm>
@code {
private LoginModel loginModel = new();
private bool isLoading;
private string? errorMessage;
private async Task HandleLogin()
{
isLoading = true;
errorMessage = null;
try
{
var response = await Http.PostAsJsonAsync("/api/auth/login", loginModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<TokenResponse>();
await AuthStateProvider.LoginAsync(result.Token, result.RefreshToken);
Navigation.NavigateTo("/");
}
else
{
errorMessage = "Invalid email or password";
}
}
catch (Exception ex)
{
errorMessage = "An error occurred. Please try again.";
Logger.LogError(ex, "Login failed");
}
finally
{
isLoading = false;
}
}
private class LoginModel
{
[Required, EmailAddress]
public string Email { get; set; } = "";
[Required]
public string Password { get; set; } = "";
}
}Additional Resources
Official Documentation:
Related Guides:
- Blazor Render Modes Explained
- Building Secure Multi-Tenant Applications
- Production Deployment Best Practices
Get the Complete Source Code
Access the full implementation with unit tests and production configurations:
- GitHub Repository
- Includes Docker configuration
- Includes security audit checklist
- Includes Postman collection for API testing
Need Expert Guidance?
Implementing authentication for enterprise systems requires attention to security, compliance, and scale. Based on experience with 10+ enterprise systems, I offer:
- Architecture Review - Validate your authentication design
- Security Audit - Ensure compliance with industry security standards
- Performance Optimization - Scale authentication for high-traffic systems
- Custom Implementation - Build authentication for your specific requirements
Schedule a consultation or reach out at lincoln@ljblab.dev.
Part 3 in the Blazor Enterprise Authentication series: “Securing Blazor for Enterprise Production Environments”