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.

SessionStorageService.cs
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.

ParseJWT.cs
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.

AuthStateProvider.cs
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.

AuthTokenHandler.cs
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

Program.cs (Blazor)
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>();