I wanted a simple, secure and effective login logic for this site, only for me to utilize my admin features such as posting this blog post and viewing some metrics. I came across this beautiful NuGet package BitzArt.Blazor.Auth that is:
A developer-friendly JWT & Cookie authentication library for Blazor.
Built for .NET 8+ and designed to make Blazor authentication less painful, more secure, and (dare we say) enjoyable.
So with this package, you can easily integrate JWT or cookie (session) authentication in your Blazor projects regardless the render mode. Dial into different login and token configurations and also use Blazor's built-in [AuthorizeView] and [CascadingAuthenticationState] in your code to secure components and get user authentication context.
Which is again from the same team, this also uses BitzArt Blazor.Cookies under the hood to store JWT key securely in an http-only cookie.
Why This Package?
Because it streamlines authentication and authorization in Blazor across render modes which has been a pain for developers for some time now. They state:
This library skips the boilerplate, handles Blazor's quirks (SSR, WASM, Server, you name it), and keeps your sanity intact.
Built by developers who have already made all the mistakes—so you don't have to.
In my situation that I'm using interactive server (for interactive client it's another story) Blazor uses long‑lived SignalR circuits and server‑side rendering, so authentication flows and token handling differ from stateless ASP.NET Core APIs. In other words, Blazor uses SignalR for request/response whereas .NET Core APIs use HTTP that you can put JWT key in the header.
HttpContext used during initial pre-rendering is captured at circuit start and is not continuously updated; tokens read from HttpContext at connection time may become stale if the user re-authenticates later.
| Platform | How token is delivered | Where token is read / stored | When validation occurs | Key implication |
|---|---|---|---|---|
| ASP.NET Core API (HttpContext) | Authorization header (Bearer) sent with every HTTP request | Read from HttpContext per request; no server token store required |
Validated per request by authentication middleware | Stateless per‑call validation; easy to enforce and scale |
| Blazor Interactive Server (SignalR) | Token provided at SignalR handshake (cookie or header); subsequent UI events do not send headers | Persisted server‑side (token store) and tied to the SignalR circuit; HttpContext captured only at handshake |
Validate on handshake, on reconnect, and before sensitive operations using server store | Must maintain server token store, perform server‑side refresh, and revalidate to avoid stale identity |
BitzArt stores the JWT in a client-side cookie, but the server uses that cookie to populate the Server Memory during the SignalR circuit lifetime. The server reads the JWT from the cookie during the initial HTTP handshake. BitzArt then maps JWT claims into the AuthenticationStateProvider. Because Blazor Server is stateful, the identity stays in server memory for as long as that specific circuit (tab) is open.
BitzArt Sign-In Schema

Setup For JWT Configurations
Package documentation is easy to follow and straightforward for setup, but it lacks JWT credential verification. Both in implementation by the library and integration in documentation.
Generally, it is very simple to do it in ASP.NET Core API projects as you cannot even miss it. Below code snippet is a sample of doing it:
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidIssuer = _configuration["JWT:Issuer"],
ValidAudience = _configuration["JWT:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Key"]))
};
});
For any authorized operation at the server boundary, BitzArt validates the JWT using the client's cookie. But by default it only verifies the role section. For example;
@attribute [Authorize(Roles = "Admin")]
<AuthorizeView Roles="Admin">
<AdminComponent />
</AuthorizeView>
Then in JWT role section: "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "Admin" matches.
First install the package
dotnet add package BitzArt.Blazor.Auth.Server
Then in program.cs
builder.AddBlazorAuth<AuthService, ValidatingClaimsService>();
builder.Services.AddAuthorization();
var app = builder.Build();
//omitted
app.MapAuthEndpoints();
await app.RunAsync();
This is what AddBlazorAuth does under the hood:
// Summary:
// Adds server-side Blazor.Auth services to the specified Microsoft.Extensions.Hosting.IHostApplicationBuilder.
//
//
// Type parameters:
// TAuthenticationService:
// The type of the server-side authentication service.
//
// TIdentityClaimsService:
// The type of the identity claims service.
public static IHostApplicationBuilder AddBlazorAuth<TAuthenticationService, TIdentityClaimsService>(this IHostApplicationBuilder builder, Action<BlazorAuthServerOptions>? configure = null) where TAuthenticationService : class, IAuthenticationService where TIdentityClaimsService : class, IIdentityClaimsService
{
BlazorAuthServerOptions blazorAuthServerOptions = new BlazorAuthServerOptions();
configure?.Invoke(blazorAuthServerOptions);
builder.Services.AddSingleton(blazorAuthServerOptions);
builder.AddBlazorCookies();
builder.Services.AddScoped<IBlazorAuthLogger, BlazorAuthLogger>();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddServerSideInteractivityStatus();
builder.Services.AddScoped<AuthenticationStateProvider, BlazorAuthAuthenticationStateProvider>();
builder.Services.AddScoped<IIdentityClaimsService, TIdentityClaimsService>();
AuthenticationServiceSignature authServiceSignature = builder.Services.AddAuthenticationService<TAuthenticationService>();
builder.Services.AddUserService(authServiceSignature);
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, CustomAuthorizationMiddlewareResultHandler>();
return builder;
}
You see it already adds CascadingAuthenticationState so you don't have to do it in your program.cs or routes component.
However, if you look at the documentation, you will see that there are no types given to AddBlazorAuth method, so it will use the default Authentication and IdentityClaims service where they have no useful implementation for production use.
So let's take a look at my version of AuthService class:
public sealed class AuthService : AuthenticationService<AdminLoginModel>
{
private const string context = "AuthService";
private static readonly PasswordHasher<string> Hasher = new();
private readonly AdminAuthOptions _admin;
private readonly JwtOptions _jwt;
private readonly RateLimiterService _rateLimiter;
private readonly IHttpContextAccessor _httpContextAccessor;
public AuthService(
IOptions<AdminAuthOptions> adminOptions,
IOptions<JwtOptions> jwtOptions,
RateLimiterService rateLimiter,
IHttpContextAccessor httpContextAccessor)
{
_admin = adminOptions.Value;
_jwt = jwtOptions.Value;
_rateLimiter = rateLimiter;
_httpContextAccessor = httpContextAccessor;
}
public override async Task<AuthenticationResult> SignInAsync(AdminLoginModel payload, CancellationToken cancellationToken = default)
{
var forwardedFor = _httpContextAccessor.HttpContext?.Request.Headers["X-Forwarded-For"].FirstOrDefault();
var ipAddress = !string.IsNullOrEmpty(forwardedFor)
? forwardedFor.Split(',')[0].Trim()
: _httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown";
var allowedTask = await _rateLimiter.IsAllowedAsync(ipAddress, context);
if (!allowedTask)
{
return Failure("Rate limit exceeded. Please try again later.");
}
if (!IsValidLogin(payload))
{
return Failure("Invalid credentials");
}
var token = CreateJwt(payload.Username);
var jwtPair = new JwtPair(
token,
DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpiryMinutes),
refreshToken: null,
refreshTokenExpiresAt: null);
return Success(jwtPair);
}
private bool IsValidLogin(AdminLoginModel payload)
{
if (!string.Equals(payload.Username, _admin.Username, StringComparison.Ordinal))
{
return false;
}
var result = Hasher.VerifyHashedPassword(
payload.Username,
_admin.PasswordHash,
payload.Password);
if (result == PasswordVerificationResult.Failed)
{
return false;
}
return true;
}
private string CreateJwt(string username)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, username),
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, "Admin")
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_jwt.ExpiryMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public override Task<AuthenticationResult> RefreshJwtPairAsync(string refreshToken, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
Process involves:
- Check for rate limiter usage.
- Verify username and password.
- Return failure or success with JWT key.
Note that I didn't implement refresh key because my use case only involves me and it's mostly trivial.
By the way, you can use Microsoft Identity to hash a password securely like in below code:
var options = new PasswordHasherOptions
{
CompatibilityMode = PasswordHasherCompatibilityMode.IdentityV3,
IterationCount = 600_000
};
var hasher = new PasswordHasher<string>(Options.Create(options));
var hash = hasher.HashPassword("user_name", "your_secure_password_here");
Console.WriteLine(hash);
// Verify
var result = hasher.VerifyHashedPassword("user_name", hash, "your_secure_password_here");
Console.WriteLine(result); // Success, Failed, or SuccessRehashNeeded
This code uses newer, safer v3 method. It is also good to put more iteration count like a million or even more maybe.
Let's see ValidatingClaimsService as well:
public class ValidatingClaimsService : IIdentityClaimsService
{
private readonly JwtOptions _jwt;
public ValidatingClaimsService(IOptions<JwtOptions> jwtOptions)
{
_jwt = jwtOptions.Value;
}
public Task<ClaimsPrincipal> BuildClaimsPrincipalAsync(string accessToken)
{
if (string.IsNullOrWhiteSpace(accessToken))
{
return Task.FromResult(new ClaimsPrincipal(new ClaimsIdentity()));
}
var tokenHandler = new JwtSecurityTokenHandler();
var validationParameters = new TokenValidationParameters
{
ValidateAudience = true,
ValidAudience = _jwt.Audience,
ValidateIssuer = true,
ValidIssuer = _jwt.Issuer,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_jwt.SigningKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
NameClaimType = ClaimTypes.Name,
RoleClaimType = ClaimTypes.Role
};
try
{
#pragma warning disable CA1849 // Call async methods when in an async method
var principal = tokenHandler.ValidateToken(
accessToken,
validationParameters,
out _);
#pragma warning restore CA1849 // Call async methods when in an async method
var identity = new ClaimsIdentity(
principal.Claims,
"jwt",
ClaimTypes.Name,
ClaimTypes.Role);
return Task.FromResult(new ClaimsPrincipal(identity));
}
catch
{
return Task.FromResult(new ClaimsPrincipal(new ClaimsIdentity()));
}
}
}
Ignore the async warnings that is interface has async suffixes only for future proofing just in case that a DB check or whatever is required for some developers. Other than that, authentication checks are synchronous.
Process involves:
- If access token is null or failed verification, return an empty identity which will trigger user not authenticated response.
- Verify JWT token against parameters shown and return a claims principle with the identity on success.
There you have it, now JWT token always will be verified against all parameters on each page navigation and logic execution.
Setup For User Interface
Now, let's take a look at routes component:
<Router AppAssembly="@typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<Authorizing>
<!-- empty - but it flashes on F5 -->
</Authorizing>
<NotAuthorized Context="auth">
@if (!auth.User.Identity?.IsAuthenticated ?? true)
{
<RedirectToLogin />
}
else
{
<AccessDenied />
}
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
We need to put the AuthorizeRouteView section to enable checks on pages.
This what a simple login page looks like:
@page "/login"
@attribute [AllowAnonymous]
@inject NavigationManager Nav
@inject IUserService<AdminLoginModel> UserService
<PageTitle>Admin Login</PageTitle>
<AuthorizeView>
<Authorized Context="auth">
<MudText Class="mr-2">
Hello, @auth.User.Identity?.Name
</MudText>
<MudButton Variant="Variant.Text"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Logout"
OnClick="Logout">
Logout
</MudButton>
</Authorized>
<NotAuthorized Context="authContext">
<MudContainer MaxWidth="MaxWidth.ExtraSmall" Class="mt-16">
<MudPaper Elevation="6" Class="pa-6">
<MudText Typo="Typo.h5" Align="Align.Center" GutterBottom>
Admin Login
</MudText>
<EditForm Model="_model" OnValidSubmit="SignInAsync">
<DataAnnotationsValidator />
<MudTextField T="string"
For="@(() => _model.Username)"
@bind-Value="_model.Username"
Label="Username" />
<MudTextField T="string"
For="@(() => _model.Password)"
@bind-Value="_model.Password"
Label="Password"
InputType="InputType.Password"
Class="mt-3" />
<MudButton ButtonType="ButtonType.Submit"
Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
Loading="_loading"
Disabled="_loading"
Class="mt-4">
@if (_loading)
{
<span class="ml-2">Signing in...</span>
}
else
{
<span>Login</span>
}
</MudButton>
@if (_error)
{
<MudAlert Severity="Severity.Error" Class="mt-3">
Invalid username or password
</MudAlert>
}
</EditForm>
</MudPaper>
</MudContainer>
</NotAuthorized>
</AuthorizeView>
@code {
private readonly AdminLoginModel _model = new();
private bool _loading;
private bool _error;
[Parameter, SupplyParameterFromQuery]
public string? ReturnUrl { get; set; }
private async Task SignInAsync()
{
if (_loading)
{
return;
}
try
{
_loading = true;
_error = false;
StateHasChanged();
await Task.Yield();
var result = await UserService.SignInAsync(_model);
if (result.IsSuccess)
{
Nav.NavigateTo(ReturnUrl ?? "/admin", true);
}
else
{
_error = true;
}
}
finally
{
_loading = false;
StateHasChanged();
await Task.Yield();
}
}
private async Task Logout()
{
await UserService.SignOutAsync();
Nav.NavigateTo("/", forceLoad: true);
}
}
And a very simple admin page to see our user name if it's working:
@page "/admin"
@attribute [Authorize(Roles = "Admin")]
<h3>Admin Panel</h3>
@if (_user is null)
{
<p>Loading...</p>
}
else
{
<p>Welcome, @_user.Identity?.Name</p>
}
@code {
[CascadingParameter]
private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!;
private ClaimsPrincipal? _user;
protected override async Task OnInitializedAsync()
{
var authState = await AuthenticationStateTask;
_user = authState.User;
}
}
You can easily test this by going to this site JTW.IO, enter your JWT key that you retrieve from your browser as shown below and tamper it after you put it into JWT.IO website, then paste it in your browser again on-the-fly while you navigating pages to see if your website let you use admin authorized pages or not.
First, hit F12 on your keyboard and get your JWT token as shown in the picture:

Paste this to the JWT.IO site, then do these things:
- Forge a similar token with different signature and test - it should FAIL.
- Forge a similar token but different role or audience etc. with the correct signature - it should FAIL.

Conclusion
This is relatively a small use case, so there wasn't any need for full blown Microsoft Identity or even Keycloak in this situation. Just a simple but very secure JWT authentication that works for one administrator on Blazor interactive server.
To note, it is recommended to use an option pattern with validation on startup for configuration values, so that when you miss crucial information like JWT signature, app. won't start. Always keep your secret values in user-secrets for development and in environment variables or key vaults for production. Test your security for forged tokens and double-check the components critical logic for authorization.
Did you spot the "flashes on F5" comment on routes component and page refresh on sign-in/out in the login component? In the next article I will write on how to do it seamlessly without screen blinks on every new circuit opening and sign-in/out immediately without a page refresh.
Till then, take it easy!
