Secure an ASP.NET Core Blazor Web App with Microsoft Entra ID

This article describes how to secure a Blazor Web App with Microsoft identity platform/Microsoft Identity Web packages for Microsoft Entra ID using a sample app.

The following specification is covered:

  • The Blazor Web App uses the Auto render mode with global interactivity (InteractiveAuto).
  • The server project calls AddAuthenticationStateSerialization to add a server-side authentication state provider that uses PersistentComponentState to flow the authentication state to the client. The client calls AddAuthenticationStateDeserialization to deserialize and use the authentication state passed by the server. The authentication state is fixed for the lifetime of the WebAssembly application.
  • The app uses Microsoft Entra ID, based on Microsoft Identity Web packages.
  • Automatic non-interactive token refresh is managed by the framework.
  • The app uses server-side and client-side service abstractions to display generated weather data:
    • When rendering the Weather component on the server to display weather data, the component uses the ServerWeatherForecaster on the server to directly obtain weather data (not via a web API call).
    • When the Weather component is rendered on the client, the component uses the ClientWeatherForecaster service implementation, which uses a preconfigured HttpClient (in the client project's Program file) to make a web API call to the server project's Minimal API (/weather-forecast) for weather data. The Minimal API endpoint obtains the weather data from the ServerWeatherForecaster class and returns it to the client for rendering by the component.

Sample app

The sample app consists of two projects:

  • BlazorWebAppEntra: Server-side project of the Blazor Web App, containing an example Minimal API endpoint for weather data.
  • BlazorWebAppEntra.Client: Client-side project of the Blazor Web App.

Access sample apps through the latest version folder from the repository's root with the following link. The projects are in the BlazorWebAppEntra folder for .NET 9 or later.

View or download sample code (how to download)

Server-side Blazor Web App project (BlazorWebAppEntra)

The BlazorWebAppEntra project is the server-side project of the Blazor Web App.

The BlazorWebAppEntra.http file can be used for testing the weather data request. Note that the BlazorWebAppEntra project must be running to test the endpoint, and the endpoint is hardcoded into the file. For more information, see Use .http files in Visual Studio 2022.

Client-side Blazor Web App project (BlazorWebAppEntra.Client)

The BlazorWebAppEntra.Client project is the client-side project of the Blazor Web App.

If the user needs to log in or out during client-side rendering, a full page reload is initiated.

Configuration

This section explains how to configure the sample app.

AddMicrosoftIdentityWebApp from Microsoft Identity Web (Microsoft.Identity.Web NuGet package, API documentation) is configured by the AzureAd section of the server project's appsettings.json file.

In the app's registration in the Entra or Azure portal, use a Web platform configuration with a Redirect URI of https://localhost/signin-oidc (a port isn't required). Confirm that ID tokens and access tokens under Implicit grant and hybrid flows are not selected. The OpenID Connect handler automatically requests the appropriate tokens using the code returned from the authorization endpoint.

Configure the app

In the server project's app settings file (appsettings.json), provide the app's AzureAd section configuration. Obtain the application (client) ID, tenant (publisher) domain, and directory (tenant) ID from the app's registration in the Entra or Azure portal:

"AzureAd": {
  "CallbackPath": "/signin-oidc",
  "ClientId": "{CLIENT ID}",
  "Domain": "{DOMAIN}",
  "Instance": "https://login.microsoftonline.com/",
  "ResponseType": "code",
  "TenantId": "{TENANT ID}"
},

Placeholders in the preceding example:

  • {CLIENT ID}: The application (client) ID.
  • {DOMAIN}: The tenant (publisher) domain.
  • {TENANT ID}: The directory (tenant) ID.

Example:

"AzureAd": {
  "CallbackPath": "/signin-oidc",
  "ClientId": "00001111-aaaa-2222-bbbb-3333cccc4444",
  "Domain": "contoso.onmicrosoft.com",
  "Instance": "https://login.microsoftonline.com/",
  "ResponseType": "code",
  "TenantId": "aaaabbbb-0000-cccc-1111-dddd2222eeee"
},

The callback path (CallbackPath) must match the redirect URI (login callback path) configured when registering the application in the Entra or Azure portal. Paths are configured in the Authentication blade of the app's registration. The default value of CallbackPath is /signin-oidc for a registered redirect URI of https://localhost/signin-oidc (a port isn't required).

Warning

Don't store app secrets, connection strings, credentials, passwords, personal identification numbers (PINs), private C#/.NET code, or private keys/tokens in client-side code, which is always insecure. In test/staging and production environments, server-side Blazor code and web APIs should use secure authentication flows that avoid maintaining credentials within project code or configuration files. Outside of local development testing, we recommend avoiding the use of environment variables to store sensitive data, as environment variables aren't the most secure approach. For local development testing, the Secret Manager tool is recommended for securing sensitive data. For more information, see Securely maintain sensitive data and credentials.

Establish the client secret

Create a client secret in the app's Entra ID registration in the Entra or Azure portal (Manage > Certificates & secrets > New client secret). Use the Value of the new secret in the following guidance.

Use either or both of the following approaches to supply the client secret to the app:

  • Secret Manager tool: The Secret Manager tool stores private data on the local machine and is only used during local development.
  • Azure Key Vault: You can store the client secret in a key vault for use in any environment, including for the Development environment when working locally. Some developers prefer to use key vaults for staging and production deployments and use the Secret Manager tool for local development.

We strongly recommend that you avoid storing client secrets in project code or configuration files. Use secure authentication flows, such as either or both of the approaches in this section.

Secret Manager tool

The Secret Manager tool can store the server app's client secret under the configuration key AzureAd:ClientSecret.

The sample app hasn't been initialized for the Secret Manager tool. Use a command shell, such as the Developer PowerShell command shell in Visual Studio, to execute the following command. Before executing the command, change the directory with the cd command to the server project's directory. The command establishes a user secrets identifier (<UserSecretsId>) in the server app's project file, which is used internally by the tooling to track secrets for the app:

dotnet user-secrets init

Execute the following command to set the client secret. The {SECRET} placeholder is the client secret obtained from the app's Entra registration:

dotnet user-secrets set "AzureAd:ClientSecret" "{SECRET}"

If using Visual Studio, you can confirm that the secret is set by right-clicking the server project in Solution Explorer and selecting Manage User Secrets.

Azure Key Vault

Azure Key Vault provides a safe approach for providing the app's client secret to the app.

To create a key vault and set a client secret, see About Azure Key Vault secrets (Azure documentation), which cross-links resources to get started with Azure Key Vault. To implement the code in this section, record the key vault URI and the secret name from Azure when you create the key vault and secret. When you set the access policy for the secret in the Access policies panel:

  • Only the Get secret permission is required.
  • Select the application as the Principal for the secret.

Important

A key vault secret is created with an expiration date. Be sure to track when a key vault secret is going to expire and create a new secret for the app prior to that date passing.

The following GetKeyVaultSecret method retrieves a secret from a key vault. Add this method to the server project. Adjust the namespace (BlazorSample.Helpers) to match your project namespace scheme.

Helpers/AzureHelper.cs:

using Azure;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;

namespace BlazorSample.Helpers;

public static class AzureHelper
{
    public static string GetKeyVaultSecret(string tenantId, string vaultUri, string secretName)
    {
        DefaultAzureCredentialOptions options = new()
        {
            // Specify the tenant ID to use the dev credentials when running the app locally
            // in Visual Studio.
            VisualStudioTenantId = tenantId,
            SharedTokenCacheTenantId = tenantId
        };

        var client = new SecretClient(new Uri(vaultUri), new DefaultAzureCredential(options));
        var secret = client.GetSecretAsync(secretName).Result;

        return secret.Value.Value;
    }
}

Where services are registered in the server project's Program file, obtain and apply the client secret using the following code:

var tenantId = builder.Configuration.GetValue<string>("AzureAd:TenantId")!;
var vaultUri = builder.Configuration.GetValue<string>("AzureAd:VaultUri")!;
var secretName = builder.Configuration.GetValue<string>("AzureAd:SecretName")!;

builder.Services.Configure<MicrosoftIdentityOptions>(
    OpenIdConnectDefaults.AuthenticationScheme,
    options =>
    {
        options.ClientSecret = 
            AzureHelper.GetKeyVaultSecret(tenantId, vaultUri, secretName);
    });

If you wish to control the environment where the preceding code operates, for example to avoid running the code locally because you've opted to use the Secret Manager tool for local development, you can wrap the preceding code in a conditional statement that checks the environment:

if (!context.HostingEnvironment.IsDevelopment())
{
    ...
}

In the AzureAd section of appsettings.json, add the following VaultUri and SecretName configuration keys and values:

"VaultUri": "{VAULT URI}",
"SecretName": "{SECRET NAME}"

In the preceding example:

  • The {VAULT URI} placeholder is the key vault URI. Include the trailing slash on the URI.
  • The {SECRET NAME} placeholder is the secret name.

Example:

"VaultUri": "https://contoso.vault.azure.net/",
"SecretName": "BlazorWebAppEntra"

Configuration is used to facilitate supplying dedicated key vaults and secret names based on the app's environmental configuration files. For example, you can supply different configuration values for appsettings.Development.json in development, appsettings.Staging.json when staging, and appsettings.Production.json for the production deployment. For more information, see ASP.NET Core Blazor configuration.

Redirect to the home page on sign out

When a user navigates around the app, the LogInOrOut component (Layout/LogInOrOut.razor) sets a hidden field for the return URL (ReturnUrl) to the value of the current URL (currentURL). When the user signs out of the app, the identity provider returns them to the page from which they signed out.

If the user signs out from a secure page, they're returned back to the same secure page after signing out only to be sent back through the authentication process. This behavior is fine when users need to switch accounts frequently. However, a alternative app specification may call for the user to be returned to the app's home page or some other page after signing out. The following example shows how to set the app's home page as the return URL for sign-out operations.

The important changes to the LogInOrOut component are demonstrated in the following example. There's no need to provide a hidden field for the ReturnUrl set to the home page at / because that's the default path. IDisposable is no longer implemented. The NavigationManager is no longer injected. The entire @code block is removed.

Layout/LogInOrOut.razor:

@using Microsoft.AspNetCore.Authorization

<div class="nav-item px-3">
    <AuthorizeView>
        <Authorized>
            <form action="authentication/logout" method="post">
                <AntiforgeryToken />
                <button type="submit" class="nav-link">
                    <span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true">
                    </span> Logout @context.User.Identity?.Name
                </button>
            </form>
        </Authorized>
        <NotAuthorized>
            <a class="nav-link" href="authentication/login">
                <span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> 
                Login
            </a>
        </NotAuthorized>
    </AuthorizeView>
</div>

Troubleshoot

Logging

The server app is a standard ASP.NET Core app. See the ASP.NET Core logging guidance to enable a lower logging level in the server app.

To enable debug or trace logging for Blazor WebAssembly authentication, see the Client-side authentication logging section of ASP.NET Core Blazor logging with the article version selector set to ASP.NET Core 7.0 or later.

Common errors

  • Misconfiguration of the app or Identity Provider (IP)

    The most common errors are caused by incorrect configuration. The following are a few examples:

    • Depending on the requirements of the scenario, a missing or incorrect Authority, Instance, Tenant ID, Tenant domain, Client ID, or Redirect URI prevents an app from authenticating clients.
    • Incorrect request scopes prevent clients from accessing server web API endpoints.
    • Incorrect or missing server API permissions prevent clients from accessing server web API endpoints.
    • Running the app at a different port than is configured in the Redirect URI of the IP's app registration. Note that a port isn't required for Microsoft Entra ID and an app running at a localhost development testing address, but the app's port configuration and the port where the app is running must match for non-localhost addresses.

    Configuration coverage in this article shows examples of the correct configuration. Carefully check the configuration looking for app and IP misconfiguration.

    If the configuration appears correct:

    • Analyze application logs.

    • Examine the network traffic between the client app and the IP or server app with the browser's developer tools. Often, an exact error message or a message with a clue to what's causing the problem is returned to the client by the IP or server app after making a request. Developer tools guidance is found in the following articles:

    The documentation team responds to document feedback and bugs in articles (open an issue from the This page feedback section) but is unable to provide product support. Several public support forums are available to assist with troubleshooting an app. We recommend the following:

    The preceding forums are not owned or controlled by Microsoft.

    For non-security, non-sensitive, and non-confidential reproducible framework bug reports, open an issue with the ASP.NET Core product unit. Don't open an issue with the product unit until you've thoroughly investigated the cause of a problem and can't resolve it on your own and with the help of the community on a public support forum. The product unit isn't able to troubleshoot individual apps that are broken due to simple misconfiguration or use cases involving third-party services. If a report is sensitive or confidential in nature or describes a potential security flaw in the product that cyberattackers may exploit, see Reporting security issues and bugs (dotnet/aspnetcore GitHub repository).

  • Unauthorized client for ME-ID

    info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2] Authorization failed. These requirements were not met: DenyAnonymousAuthorizationRequirement: Requires an authenticated user.

    Login callback error from ME-ID:

    • Error: unauthorized_client
    • Description: AADB2C90058: The provided application is not configured to allow public clients.

    To resolve the error:

    1. In the Azure portal, access the app's manifest.
    2. Set the allowPublicClient attribute to null or true.

Cookies and site data

Cookies and site data can persist across app updates and interfere with testing and troubleshooting. Clear the following when making app code changes, user account changes with the provider, or provider app configuration changes:

  • User sign-in cookies
  • App cookies
  • Cached and stored site data

One approach to prevent lingering cookies and site data from interfering with testing and troubleshooting is to:

  • Configure a browser
    • Use a browser for testing that you can configure to delete all cookie and site data each time the browser is closed.
    • Make sure that the browser is closed manually or by the IDE for any change to the app, test user, or provider configuration.
  • Use a custom command to open a browser in InPrivate or Incognito mode in Visual Studio:
    • Open Browse With dialog box from Visual Studio's Run button.
    • Select the Add button.
    • Provide the path to your browser in the Program field. The following executable paths are typical installation locations for Windows 10. If your browser is installed in a different location or you aren't using Windows 10, provide the path to the browser's executable.
      • Microsoft Edge: C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe
      • Google Chrome: C:\Program Files (x86)\Google\Chrome\Application\chrome.exe
      • Mozilla Firefox: C:\Program Files\Mozilla Firefox\firefox.exe
    • In the Arguments field, provide the command-line option that the browser uses to open in InPrivate or Incognito mode. Some browsers require the URL of the app.
      • Microsoft Edge: Use -inprivate.
      • Google Chrome: Use --incognito --new-window {URL}, where the {URL} placeholder is the URL to open (for example, https://localhost:5001).
      • Mozilla Firefox: Use -private -url {URL}, where the {URL} placeholder is the URL to open (for example, https://localhost:5001).
    • Provide a name in the Friendly name field. For example, Firefox Auth Testing.
    • Select the OK button.
    • To avoid having to select the browser profile for each iteration of testing with an app, set the profile as the default with the Set as Default button.
    • Make sure that the browser is closed by the IDE for any change to the app, test user, or provider configuration.

App upgrades

A functioning app may fail immediately after upgrading either the .NET Core SDK on the development machine or changing package versions within the app. In some cases, incoherent packages may break an app when performing major upgrades. Most of these issues can be fixed by following these instructions:

  1. Clear the local system's NuGet package caches by executing dotnet nuget locals all --clear from a command shell.
  2. Delete the project's bin and obj folders.
  3. Restore and rebuild the project.
  4. Delete all of the files in the deployment folder on the server prior to redeploying the app.

Note

Use of package versions incompatible with the app's target framework isn't supported. For information on a package, use the NuGet Gallery or FuGet Package Explorer.

Run the server app

When testing and troubleshooting Blazor Web App, make sure that you're running the app from the server project.

Inspect the user

The following UserClaims component can be used directly in apps or serve as the basis for further customization.

UserClaims.razor:

@page "/user-claims"
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]

<PageTitle>User Claims</PageTitle>

<h1>User Claims</h1>

@if (claims.Any())
{
    <ul>
        @foreach (var claim in claims)
        {
            <li><b>@claim.Type:</b> @claim.Value</li>
        }
    </ul>
}

@code {
    private IEnumerable<Claim> claims = Enumerable.Empty<Claim>();

    [CascadingParameter]
    private Task<AuthenticationState>? AuthState { get; set; }

    protected override async Task OnInitializedAsync()
    {
        if (AuthState == null)
        {
            return;
        }

        var authState = await AuthState;
        claims = authState.User.Claims;
    }
}

Additional resources