JWT Auth State (Blazor)
How a Blazor WASM/Server app keeps track of "who's logged in" from a raw JWT string: a custom AuthenticationStateProvider, a DelegatingHandler that attaches the token to outgoing requests, and a small parser to turn the token's payload into Claims.
1. Storage
A thin wrapper around sessionStorage via JS interop. Swap for localStorage calls if you want the session to survive a browser restart.
public class SessionStorageService
{
private readonly IJSRuntime _js;
public SessionStorageService(IJSRuntime js) => _js = js;
public async Task SetItemAsync(string key, string value)
=> await _js.InvokeVoidAsync("sessionStorage.setItem", key, value);
public async Task<string?> GetItemAsync(string key)
=> await _js.InvokeAsync<string?>("sessionStorage.getItem", key);
public async Task RemoveItemAsync(string key)
=> await _js.InvokeVoidAsync("sessionStorage.removeItem", key);
}
2. Parsing the JWT payload into Claims
No library needed — a JWT's middle segment is base64url-encoded JSON. Decode it, deserialize to a dictionary, and turn each entry into a Claim.
public static class ParseJWT
{
public static List<Claim> GetClaims(string token)
{
var payload = token.Split('.').ElementAtOrDefault(1);
if (payload is null) return [];
// base64url -> base64
payload = payload.Replace('-', '+').Replace('_', '/');
payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=');
byte[] bytes;
try { bytes = Convert.FromBase64String(payload); }
catch { return []; }
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(bytes);
if (dict is null) return [];
var claims = new List<Claim>();
foreach (var pair in dict)
{
if (pair.Value.ValueKind == JsonValueKind.Array)
foreach (var item in pair.Value.EnumerateArray())
claims.Add(new Claim(pair.Key, item.ToString()));
else if (pair.Value.ValueKind != JsonValueKind.Null)
claims.Add(new Claim(pair.Key, pair.Value.ToString()));
}
return claims;
}
}
3. The AuthenticationStateProvider
Blazor asks this class "who is the current user?" — it reads the stored token, checks the exp claim itself (no server round-trip needed), and builds a ClaimsPrincipal from it.
public class AuthStateProvider : AuthenticationStateProvider
{
private const string TokenKey = "AUTH_TOKEN";
private readonly AuthenticationState _anonymous =
new(new ClaimsPrincipal(new ClaimsIdentity()));
private readonly SessionStorageService _storage;
public AuthStateProvider(SessionStorageService storage) => _storage = storage;
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var token = await _storage.GetItemAsync(TokenKey);
if (string.IsNullOrWhiteSpace(token)) return _anonymous;
var claims = ParseJWT.GetClaims(token);
if (IsExpired(claims))
{
await _storage.RemoveItemAsync(TokenKey);
return _anonymous;
}
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, "JWT")));
}
public async Task MarkUserAsAuthenticatedAsync(string token)
{
await _storage.SetItemAsync(TokenKey, token);
var claims = ParseJWT.GetClaims(token);
NotifyAuthenticationStateChanged(Task.FromResult(
new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, "JWT")))));
}
public async Task MarkUserAsLoggedOutAsync()
{
await _storage.RemoveItemAsync(TokenKey);
NotifyAuthenticationStateChanged(Task.FromResult(_anonymous));
}
public async Task<string?> GetTokenAsync() => await _storage.GetItemAsync(TokenKey);
private static bool IsExpired(List<Claim> claims)
{
var exp = claims.FirstOrDefault(c => c.Type == "exp");
if (exp is null || !long.TryParse(exp.Value, out var seconds)) return true;
return DateTimeOffset.FromUnixTimeSeconds(seconds) <= DateTimeOffset.UtcNow;
}
}
4. Attaching the token to every HTTP call
A DelegatingHandler plugged into the named HttpClient means no service ever has to remember to add the header manually.
public class AuthTokenHandler : DelegatingHandler
{
private readonly AuthStateProvider _authState;
public AuthTokenHandler(AuthStateProvider authState) => _authState = authState;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var token = await _authState.GetTokenAsync();
if (!string.IsNullOrWhiteSpace(token))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, ct);
}
}
5. Wiring it up
builder.Services.AddSingleton<SessionStorageService>();
builder.Services.AddSingleton<AuthStateProvider>();
builder.Services.AddSingleton<AuthenticationStateProvider>(sp =>
sp.GetRequiredService<AuthStateProvider>());
builder.Services.AddTransient<AuthTokenHandler>();
builder.Services.AddAuthorizationCore();
builder.Services.AddHttpClient("", options =>
{
options.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!);
}).AddHttpMessageHandler<AuthTokenHandler>();