Posted on

Blazor WebAssembly with JWT Authentication on Web Api Server

After configuring authentication (see previous section), the logical next step is to make your Blazor app communicate securely with your Web API server.

The “Handshake” Summary

  • Auth0: Issues the “passport” (Token) to Blazor.

  • Blazor: Takes the passport, puts it into an envelope (HTTP Header), and sends it to the server.

  • Server (JWT): Says: “I’ll check the passport: it’s signed by Auth0 and it’s valid. You may proceed!”


Configuration on Auth0.com

Single Page Applications

Regular Web Applications (such as Blazor Server) and Single Page Applications (such as Blazor WASM) use two completely different approaches to security:

  • Blazor Server (Regular Web App):
    The “secret” lives on the server. It can safely manage client secrets and protected token exchanges behind the scenes.

  • Blazor WASM (SPA):
    The code runs in the user’s browser. It cannot keep secrets (anyone could press F12 and see them). It uses the PKCE flow (without a client secret).

In our case, it is essential that the Auth0 application is configured as a Single Page Application, otherwise Auth0 will not issue an access token.

In summary:

  • ID Token:
    This is your ID card. Blazor uses it to know who you are (name, email). The audience (aud) is correctly set to the ClientId.
    It cannot be used to call APIs.

  • Access Token:
    This is your “pass” to the server. Its audience (aud) must be the URL of your API.
    If this is missing, the API server sees your ID card and says:
    “I don’t care who you are, I need a pass for this resource.”

Make sure your Auth0 application is a Single Page Application.


Scopes in the Auth0 API

Make sure that the scope you request in code is registered in the API:

Auth0 Dashboard → APIs → [Your API] → Permissions tab

Verify that the exact permission leggi:miecose exists; otherwise, add it.


Web API Server

Your server exposing APIs (so far unprotected) is most likely a classic ASP.NET Core Web API project (controller-based).
This is the most solid and common structure for enterprise APIs.

Install JWT Authentication

First, install the component that handles JWT tokens.
Open the terminal in Visual Studio (or the NuGet Package Manager) and install:

Microsoft.AspNetCore.Authentication.JwtBearer

If your project targets .NET 8 and existing packages are at version 8.0.7, installing a 10.0.0 package (which belongs to a future or preview .NET version) will almost certainly cause dependency conflicts and build-time bugs.

Keeping everything on v8.x.x ensures all components speak the same language.


Configuration in Program.cs

Open Program.cs.
We need to add the authentication service and tell the API to use Auth0.

Locate the section where services are registered (before var app = builder.Build();) and paste the following:

using Microsoft.AspNetCore.Authentication.JwtBearer;

#region Auth0
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
    options.Authority = $"https://{builder.Configuration["Auth0:Domain"]}/";
    options.Audience = builder.Configuration["Auth0:Audience"];
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true
    };
});
#endregion

Then scroll down to where the pipeline is configured (after var app = builder.Build();) and add these lines in this exact order (they must be between UseRouting and MapControllers):

app.UseRouting(); // Add this if it’s missing; it helps manage routes properly

app.UseAuthentication(); // Must come BEFORE UseAuthorization

app.UseAuthorization();

app.MapControllers();

CORS

Since your Web API server and your Blazor app are likely running on different ports or domains, the server must explicitly allow Blazor to call it.

In the API server’s Program.cs, make sure CORS is configured:

// In the API server
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.WithOrigins("https://localhost:XXXX") // Your Blazor WASM URL
              .AllowAnyHeader()
              .AllowAnyMethod();
    });
});

// ... after builder.Build()
app.UseCors();

Or you can use an “open” policy (if your security scenario allows it):

// OPEN TO ALL
builder.Services.AddCors(options =>
{
    options.AddPolicy("OpenPolicy", policy =>
    {
        policy.AllowAnyOrigin() // Accept calls from anyone (including Blazor WASM)
              .AllowAnyHeader()
              .AllowAnyMethod();
    });
});

Security note:
With Auth0 authentication enabled, even if CORS is “open”, nobody can read protected data without a valid token. You are still safe.


Configure Credentials in appsettings.json

Open appsettings.json in the API project and add the data from the Auth0 dashboard (API section):

{
  "Auth0": {
    "Domain": "[YOUR-TENANT].auth0.com",
    "Authority": "https://[YOUR-TENANT].auth0.com",
    "Audience": "https://api.yourservice.com"
  }
}

Test Controller

Create a controller to test authorized calls (e.g. AuthController.cs):

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
// This API is protected!
[HttpGet("test-private")]
[Authorize]
public IActionResult GetPrivate()
{
return Ok(new { Message = "If you see this, authentication works!" });
}

// This API is public (like your existing ones)
[HttpGet("test-public")]
public IActionResult GetPublic()
{
return Ok(new { Message = "This is a public API." });
}
}

How to Verify It Works

If you try calling the API directly from Swagger after these changes, you should get a 401 Unauthorized error.
(The public endpoints will still return 200 OK as usual.)

That’s the sign that protection is active.


Changes to the Blazor WebAssembly Client

We need to instruct Blazor to use a special MessageHandler.
This component intercepts outgoing requests and automatically adds: Authorization: Bearer <TOKEN>

Configuration in Program.cs

Compared to the previous version, we must force the PKCE flow in Auth0 configuration and add the HTTP client for calls to our server:

using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using TestBlazorWasmArticolo;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

// =========================================================
// >>> BASE REGISTRATIONS (Must come before AddOidcAuthentication) <<<
// =========================================================
builder.Services.AddOptions();
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<SignOutSessionStateManager>();
// =========================================================

// =========================================================
// >>> Crucial part for Auth0/OIDC authentication <<<
// =========================================================
builder.Services.AddOidcAuthentication(options =>
{
// Audience setting (CRUCIAL for calling APIs)
// The audience must be passed as an additional OIDC parameter
options.ProviderOptions.AdditionalProviderParameters.Add(
"audience",
builder.Configuration["Auth0:Audience"]!
);

// Set the Authority explicitly
options.ProviderOptions.Authority = builder.Configuration["Auth0:Authority"]!;

// Set the ClientId
options.ProviderOptions.ClientId = builder.Configuration["Auth0:ClientId"]!;

// 2. Force the PKCE flow
options.ProviderOptions.ResponseType = "code";

// Add all required scopes, including custom ones
options.ProviderOptions.DefaultScopes.Add("openid");
options.ProviderOptions.DefaultScopes.Add("profile");
options.ProviderOptions.DefaultScopes.Add("email");
options.ProviderOptions.DefaultScopes.Add("address");
options.ProviderOptions.DefaultScopes.Add("phone");
options.ProviderOptions.DefaultScopes.Add("leggi:miecose"); // <--- API scope

// FORCE CONSENT REQUEST
// options.ProviderOptions.AdditionalProviderParameters.Add("prompt", "consent");
});

// =========================================================

#region API
string urlservice = "https://" + builder.Configuration["TestWebservice:Address"] + "/";
builder.Services.AddScoped<AuthorizationMessageHandler>();

// Register HttpClient using the factory
builder.Services.AddHttpClient("ApiGateway", client =>
{
client.BaseAddress = new Uri(urlservice);
})
.AddHttpMessageHandler(sp =>
{
// Configure the handler on the fly
return sp.GetRequiredService<AuthorizationMessageHandler>()
.ConfigureHandler(
authorizedUrls: new[] { urlservice },
scopes: new[] { "leggi:miecose" }
);
});
#endregion

// HttpClient for local calls (weather, etc.)
builder.Services.AddScoped(sp =>
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

await builder.Build().RunAsync();

ResponseType = "code" tells Blazor and Auth0 to use the PKCE flow, which is the modern standard for SPAs and solves many “lockup” issues after redirects.


Creating the Protected Page

Now create a new Razor page, for example FetchDati.razor.
This page should only be accessible to logged-in users.

@page "/fetch-dati"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject IHttpClientFactory ClientFactory
@inject IAccessTokenProvider TokenProvider
@inject NavigationManager Navigation
<PageTitle>Protected Data</PageTitle>

<AuthorizeView>
<Authorized>
<h3>Data from Server</h3>
@if (messaggioDalServer == null)
{
<p>Loading data...</p>
}
else
{
<div class="alert alert-success">@messaggioDalServer</div>
}

<h3>API Gateway Test</h3>
<button class="btn btn-success" @onclick="CaricaDatiConToken">
Load Data with Token
</button>

<p>@messaggioDalServer</p>
</Authorized>

<Authorizing>
<p>Checking authorization...</p>
</Authorizing>
</AuthorizeView>

@code {
private string? messaggioDalServer = "hello";

protected override async Task OnInitializedAsync()
{
try
{
var client = ClientFactory.CreateClient("ApiGateway");

// First, try a very simple call to check if the token is the issue
var response = await client.GetAsync("api/Auth/test-private");

if (response.IsSuccessStatusCode)
{
messaggioDalServer = await response.Content.ReadAsStringAsync();
}
else
{
messaggioDalServer = $"Server error: {response.StatusCode}";
}
}
catch (AccessTokenNotAvailableException ex)
{
// If the token fails, redirect
ex.Redirect();
}
catch (Exception ex)
{
// Network or other error: log it but don’t block the app
messaggioDalServer = $"Technical error: {ex.Message}";
Console.WriteLine(ex.ToString());
}
}

private async Task CaricaDatiConToken()
{
// 1. Request the token manually
var tokenResult = await TokenProvider.RequestAccessToken();

if (tokenResult.TryGetToken(out var token))
{
try
{
var client = ClientFactory.CreateClient("ApiGateway");

// 2. Manually add the token to the header
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", token.Value);

var response = await client.GetAsync("api/auth/test-private");
messaggioDalServer = await response.Content.ReadAsStringAsync();
}
catch (Exception ex)
{
messaggioDalServer = $"Call error: {ex.Message}";
}
}
else
{
// This tells you WHY it failed (e.g. "consent_required", "invalid_scope")
if (tokenResult.Status == AccessTokenResultStatus.RequiresRedirect)
{
messaggioDalServer = "Error: a redirect is required for consent.";
Console.WriteLine("tokenResult.InteractiveRequestUrl: "
+ tokenResult.InteractiveRequestUrl);

// Navigation.NavigateTo(tokenResult.InteractiveRequestUrl);
// Uncomment if you want to force consent
}
else
{
messaggioDalServer = $"GetToken error: {tokenResult.Status}";
}
}
}

public class ApiResponse
{
public string Message { get; set; } = "";
}
}

What to Do Now

The “engine” is installed and synchronized.
Now all that’s left is to start it:

  1. Start the Web API server (make sure it runs on HTTPS).

  2. Start the Blazor WASM app.

  3. Navigate to the fetch-dati.razor page.