· 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 RepositoryUse This Full Guide
Learning JWT authentication basicsBuilding hybrid render mode apps
Standard Blazor Server appsApps using SSR + Server + WASM
Separate API architectureNeed comprehensive prerendering support
Interactive Server mode onlyUsing 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

ParameterTypeDefaultRequiredDescription
AccessTokenKeystring"auth_token"YesStorage key for access token
RefreshTokenKeystring"refresh_token"YesStorage key for refresh token
TokenExpiryKeystring"token_expiry"NoStorage key for expiration timestamp
CacheExpiryTimeSpan5 minutesNoIn-memory token cache duration
RefreshBufferTimeSpan1 minuteNoTime before expiry to trigger refresh
OptionTypeRecommended ValueSecurity Impact
HttpOnlybooltrueCritical - Prevents JavaScript access, protects against XSS
SecurebooltrueCritical - Requires HTTPS, prevents interception
SameSiteSameSiteModeStrictHigh - Prevents CSRF attacks
ExpiresDateTimeOffset7 days (access), 30 days (refresh)Medium - Balances security and UX

Session Configuration Options

OptionTypeRecommended ValueDescription
IdleTimeoutTimeSpan30 minutesHow long before inactive session expires
Cookie.Namestring.YourApp.SessionSession cookie identifier
Cookie.IsEssentialbooltrueBypasses GDPR consent requirements

⚠️ Warning: Setting HttpOnly to false creates a critical security vulnerability. Client-side JavaScript can access tokens, enabling XSS attacks to steal authentication credentials.

Configuration Matrix

Development vs. Production Settings

SettingDevelopmentProductionWhy Different?
Cookie.SecurefalsetrueDevelopment uses HTTP, production requires HTTPS
Cookie.SameSiteLaxStrictDevelopment needs flexibility, production prioritizes security
Logging LevelDebugInformationDevelopment needs detailed traces, production reduces noise
Token CacheDisabled5 minutesDevelopment needs immediate updates, production optimizes performance
Session Timeout60 minutes30 minutesDevelopment reduces interruptions, production reduces exposure

Render Mode Storage Strategy

Render ModePrimary StorageFallback StorageCache LayerPrerender Safe?
SSRSessionCookieMemory✅ Yes
ServerSession + CookieN/AMemory✅ Yes
WASMLocalStorageCookieMemory❌ No - use fallback
AutoHybrid (all layers)SessionMemory⚠️ 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 InteractiveAuto to 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.Session and HttpContext.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:

  1. First pass (prerendering): Generates static HTML on server
  2. 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 ValueTask instead of Task reduces 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 InteractiveAuto to 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() before UseAuthentication(), 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 MessageRoot CauseSolution
JavaScript interop calls cannot be issued at this timeCalling JS during prerenderingCheck IsPrerendering before JS calls, use server-side fallback
Cannot access a disposed objectAttempting to write to response after it startedCheck !context.Response.HasStarted before session writes
Session has not been configured for this applicationMissing session middlewareAdd builder.Services.AddSession() and app.UseSession()
Token not persisting across requestsMiddleware order incorrectEnsure UseSession() comes before authentication middleware
Authentication state not updatingMissing state change notificationCall NotifyAuthenticationStateChanged() after login/logout
Cookies not being setHTTPS required but using HTTPSet Cookie.Secure = false in development or use HTTPS
Token refresh fails silentlyHttpClient not configuredVerify HttpClient base address and ensure API endpoint exists
Claims not mapping to authorizationClaim types don’t match .NET typesUse MapStandardClaims() to convert JWT claims to ClaimTypes
Token not available in localStorageTokenBridge not implemented or JWT claim missingImplement 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 Secure flag set to true
  • Cookie HttpOnly flag set to true
  • 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 TypeRecommended ExpirationRationale
Access Token15-30 minutesLimits exposure if compromised
Refresh Token7-30 daysBalances security with user experience
Session30-60 minutes idleProtects inactive sessions
Remember Me30-90 daysExtended 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:

  1. Implement Login Component - Create UI for user authentication (see example below)
  2. Add Authorization Policies - Define role-based or claims-based policies
  3. Configure Token Refresh API - Implement server endpoint for token renewal
  4. Test All Render Modes - Validate SSR, Server, WASM, and Auto modes
  5. Enable Monitoring - Set up Application Insights or similar telemetry
  6. 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:

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”

Back to Blog

Related Posts

View All Posts »