A Secure API and a Blazor App in One ASP.NET Core Project with Microsoft Entra ID
One ASP.NET Core project can serve both a Blazor web app and a web API protected by Microsoft Entra ID (the new name of Azure Active Directory). The trick is to register two authentication schemes: OpenID Connect with a cookie for people using the Blazor UI, and JWT bearer tokens for programs calling the API, then tell each endpoint which scheme it accepts. Microsoft Identity Web wires both with a few lines.
The code targets the Blazor Web App template of current .NET releases and the Microsoft.Identity.Web package. It follows Microsoft's documented APIs but was not compiled for this article; check names against the version you install.
The design in one picture
| Caller | How it authenticates | Scheme |
|---|---|---|
| A person in the browser using Blazor pages | Signs in with Entra ID; the server keeps a session cookie | OpenID Connect + cookie |
Blazor WebAssembly components calling /api/... on the same site | Same cookie, sent automatically (backend-for-frontend) | Cookie |
| Another service, a script or a mobile app | Gets an access token from Entra ID and sends Authorization: Bearer ... | JWT bearer |
Keeping tokens on the server and letting the browser use only an HTTP-only cookie is the pattern Microsoft recommends for Blazor Web Apps (often called backend-for-frontend, or BFF). Bearer tokens are for non-browser clients.
1. Register the applications in Entra ID
- Web app registration. In the Entra admin center, register an application with a Web redirect URI such as
https://localhost:5001/signin-oidcand a front-channel logout URL. Note the tenant ID and client ID. - Expose an API. In the same registration (or a separate one if you prefer to separate concerns), set an Application ID URI such as
api://<client-id>and add a scope, for exampleForecast.Read. For daemon clients without a user, define an app role instead, since app-only tokens carry roles rather than scopes. - Client registrations. Each external caller gets its own registration with permission to that scope or role, granted by an administrator where needed.
Store secrets or certificates for the web app in user secrets locally and in Azure Key Vault or managed identity in production, never in appsettings.json.
2. Configuration
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "<tenant-id>",
"ClientId": "<client-id>",
"CallbackPath": "/signin-oidc",
"SignedOutCallbackPath": "/signout-callback-oidc"
},
"AzureAdApi": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "<tenant-id>",
"ClientId": "<client-id>",
"Audience": "api://<client-id>"
}
}
Two sections keep the web sign-in and the API token validation independent, which helps when the API later moves to its own registration.
3. Program.cs: two schemes, one app
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.UI;
var builder = WebApplication.CreateBuilder(args);
// People: OpenID Connect sign-in, session kept in a cookie (default scheme)
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
// Programs: JWT bearer tokens issued by Entra ID for our API
builder.Services.AddAuthentication()
.AddMicrosoftIdentityWebApi(builder.Configuration, "AzureAdApi",
JwtBearerDefaults.AuthenticationScheme);
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ApiCaller", policy => policy
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.RequireScope("Forecast.Read"));
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddMicrosoftIdentityConsentHandler();
builder.Services.AddControllersWithViews().AddMicrosoftIdentityUI();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
// External API: bearer tokens only, scope required
app.MapGet("/api/forecast", () => Forecast.Next(5))
.RequireAuthorization("ApiCaller");
// Same-site API for Blazor WebAssembly components: the sign-in cookie
app.MapGet("/bff/forecast", () => Forecast.Next(5))
.RequireAuthorization();
app.MapControllers(); // MicrosoftIdentity/Account/SignIn and SignOut
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
What each part does:
AddMicrosoftIdentityWebAppadds OpenID Connect plus a cookie scheme and makes OpenID Connect the default challenge, so an anonymous visitor to a protected page is redirected to Entra ID.AddMicrosoftIdentityWebApiadds a JWT bearer scheme that validates the token's signature, issuer, lifetime and audience against the configuration.- The
ApiCallerpolicy pins/api/forecastto the bearer scheme and requires the scope. WithoutAddAuthenticationSchemes, the endpoint would use the default scheme and answer an API client with a 302 redirect to a login page instead of a clean 401. /bff/forecastuses the default policy, so the cookie authenticates same-site calls from interactive components.
For app-only callers, replace RequireScope with RequireRole("Forecast.Read.All"), or accept either with a custom requirement.
4. Protect Blazor pages
Add @attribute [Authorize] to a routable component to require sign-in, and use <AuthorizeView> to show content by state:
@page "/forecast"
@attribute [Authorize]
<AuthorizeView>
<Authorized>Signed in as @context.User.Identity?.Name</Authorized>
</AuthorizeView>
Components that run on the server can call your own services directly; there is no need for them to go through HTTP to /api. WebAssembly components run in the browser and call /bff/... with an HttpClient whose base address is the site, so the cookie is sent automatically and no token ever reaches the browser.
5. Call the API from another program
A client acquires a token for api://<client-id>/.default (app-only) or for the delegated scope, then sends it in the Authorization header. Any HTTP library works; in Python, for example, the MSAL library obtains the token and urllib3 sends the request, as shown in sending HTTP requests with urllib3.
Common problems
- API calls return 302 instead of 401. The endpoint uses the cookie or OpenID Connect scheme; pin it to the bearer scheme with a policy.
- 401 with a valid token. The
audclaim does not matchAudience, or the token was issued for Microsoft Graph instead of your API. Decode it locally and compare. - 403 with a valid token. The scope or role is missing: check API permissions and admin consent on the client registration.
- Redirect URI mismatch at sign-in. The URI in the registration must match scheme, host, port and path exactly, including behind a reverse proxy (configure forwarded headers).
- Antiforgery errors on POST to
/bffendpoints. Cookie-authenticated endpoints that change data need antiforgery protection; bearer endpoints do not.
Summary
Register OpenID Connect plus cookie for the Blazor UI with AddMicrosoftIdentityWebApp, add a JWT bearer scheme for external callers with AddMicrosoftIdentityWebApi, and use an authorization policy that names the bearer scheme and required scope on API endpoints. Let in-browser components use same-site cookie endpoints, keep tokens on the server, and keep secrets out of configuration files.
Sources
- Microsoft Learn, Secure an ASP.NET Core Blazor Web App with Microsoft Entra ID.
- Microsoft Learn, Protected web API: Code configuration.
- Microsoft Learn, Authorize with a specific scheme in ASP.NET Core.
WebWhat is a CRUD application?
CRUD means Create, Read, Update, Delete. See how it maps to SQL and REST HTTP methods, with a runnable Python example, validation, soft deletes and idempotency.
WebEvent calendar in Laravel
Build an event calendar in Laravel 10/11 with FullCalendar v6: migration, JSON feed for the visible range, create by selection, drag-and-drop and delete.
WebHTTP requests with urllib3
How to send GET and POST requests with urllib3: PoolManager, query fields, JSON bodies, headers, timeouts, Retry, redirects, TLS, streaming and errors (v2).