Most .NET developers know they should use Microsoft Entra ID (formerly Azure AD) to secure their APIs.
But when you actually sit down to do it, the questions start:
- What is the minimum I need to configure in Entra ID?
- How do I wire that into a .NET 8 Minimal API?
- How do roles and scopes show up inside my controllers/handlers?
- What changes when I deploy to Azure App Service?
- How do I test this with Postman or another client?
In this post we’ll build a small but professional .NET 8 Web API secured by Microsoft Entra ID, with:
- Clean project structure
-
JWT auth wired through
Microsoft.Identity.Web -
Role-based authorization (
Api.Admin) - Endpoints you can test immediately with a real Entra ID tenant
- A clear path to Azure App Service deployment
If you can run dotnet new webapi, you can follow this.
Table of Contents
- Mental Model: What You Actually Need from Entra ID
- Project Setup: .NET 8 Web API + Microsoft.Identity.Web
- Configuration: appsettings.json for AzureAd
- Program.cs: Authentication, Authorization, and Test Endpoints
- Local Execution: launchSettings and dotnet run
- Entra ID Setup: App Registration, Scopes, and App Roles
- Testing with Postman (or Any OAuth2 Client)
- Publishing to Azure App Service
- Common Pitfalls and How to Avoid Them
- Production Checklist
1. Mental Model: What You Actually Need from Entra ID
Forget the marketing buzzwords for a moment.
For a Web API you really need just three things from Microsoft Entra ID:
- An App Registration representing your API
- One or more scopes that clients request (e.g.
api://{CLIENT_ID}/Api.Read) - Optionally, App Roles (like
Api.Admin) to drive role-based authorization in your code
Everything else (SSO, groups, Graph integration, etc.) is built on top of that.
Conceptually:
-
The API says:
“I will accept tokens issued for my audience
api://{CLIENT_ID}.” -
The client (SPA, mobile app, server-to-server) says:
“Please give me an access token for the scope
api://{CLIENT_ID}/Api.Read.” -
Entra ID issues a JWT with:
-
aud(audience) = your API’s Application ID URI -
scp(scopes) = e.g.Api.Read -
roles(if you configured App Roles) = e.g.Api.Admin
-
Then your .NET 8 API just needs to:
- Validate the token (signature, issuer, audience)
- Enforce [Authorize] and roles/policies
Let’s build that.
2. Project Setup: .NET 8 Web API + Microsoft.Identity.Web
Create a clean project:
dotnet new webapi -n EntraIdDemo.Api
cd EntraIdDemo.Api
We’ll use .NET 8 and Microsoft.Identity.Web to integrate with Microsoft Entra ID.
2.1. EntraIdDemo.Api.csproj
Replace your .csproj with this:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Identity.Web" Version="2.17.4" />
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="2.17.4" />
</ItemGroup>
</Project>
💡 Version numbers change. In real life you can just run:
dotnet add package Microsoft.Identity.Web dotnet add package Microsoft.Identity.Web.MicrosoftGraphand let the CLI choose the latest compatible version.
This gives you:
- JWT bearer auth integration with Entra ID
- Optional Microsoft Graph integration later (not required for this basic API)
3. Configuration: appsettings.json for AzureAd
We’ll keep configuration minimal and environment-friendly.
3.1. appsettings.json (production template)
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "YOUR_TENANT_ID",
"ClientId": "YOUR_API_APP_CLIENT_ID",
"Domain": "yourtenant.onmicrosoft.com",
"Audience": "api://YOUR_API_APP_CLIENT_ID"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
- TenantId → Entra ID Directory (tenant) ID (GUID)
- ClientId → App registration Application (client) ID of the API
-
Audience → Should match your Application ID URI (we’ll configure this later as
api://{CLIENT_ID})
In Azure, you’ll typically override these via Application Settings, not hardcode them.
3.2. appsettings.Development.json
For local dev it’s common to use a different tenant or test app registration:
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "YOUR_DEV_TENANT_ID",
"ClientId": "YOUR_DEV_API_CLIENT_ID",
"Domain": "yourdevtenant.onmicrosoft.com",
"Audience": "api://YOUR_DEV_API_CLIENT_ID"
}
}
ASP.NET Core merges these settings when ASPNETCORE_ENVIRONMENT=Development.
4. Program.cs: Authentication, Authorization, and Test Endpoints
Now the fun part: wiring the pipeline.
We’ll use Minimal APIs with:
- Authentication via
AddMicrosoftIdentityWebApi - A policy that requires an
Api.Adminrole - Three endpoints:
-
/api/public— no auth -
/api/secure— any valid token -
/api/admin-only— requires roleApi.Admin
-
4.1. Full Program.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
var builder = WebApplication.CreateBuilder(args);
// 1. Authentication with Microsoft Entra ID (Azure AD)
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"),
jwtBearerOptions =>
{
// Extra Audience validation (recommended)
var audience = builder.Configuration["AzureAd:Audience"];
if (!string.IsNullOrWhiteSpace(audience))
{
jwtBearerOptions.TokenValidationParameters.ValidAudience = audience;
}
});
// 2. Authorization: policies, roles, etc.
builder.Services.AddAuthorization(options =>
{
// Policy that requires Api.Admin role in "roles" claim
options.AddPolicy("RequireApiAdmin", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("Api.Admin");
});
});
var app = builder.Build();
app.UseHttpsRedirection();
// 3. Plug authentication & authorization into the middleware pipeline
app.UseAuthentication();
app.UseAuthorization();
// 4. Test endpoints
// 4.1 Public (no token)
app.MapGet("/api/public", () =>
{
return Results.Ok(new
{
Message = "Public endpoint - no token required",
Time = DateTime.UtcNow
});
});
// 4.2 Secure: requires any valid token issued to this API
app.MapGet("/api/secure", (ClaimsPrincipal user) =>
{
var oid = user.FindFirst("oid")?.Value; // Object ID of user/app
var name = user.FindFirst("name")?.Value;
var roles = user.Claims
.Where(c => c.Type == "roles")
.Select(c => c.Value)
.ToArray();
var scopes = user.Claims
.Where(c => c.Type == "scp")
.Select(c => c.Value)
.ToArray();
return Results.Ok(new
{
Message = "Secure endpoint - valid token required",
UserName = name,
ObjectId = oid,
Roles = roles,
Scopes = scopes
});
})
.RequireAuthorization();
// 4.3 Admin-only: requires Api.Admin role
app.MapGet("/api/admin-only", () =>
{
return Results.Ok(new
{
Message = "Admin-only endpoint. You have Api.Admin role."
});
})
.RequireAuthorization("RequireApiAdmin");
app.Run();
At this point, the API is fully wired for JWT auth and roles. Next, we make it easy to run locally.
5. Local Execution: launchSettings and dotnet run
Visual Studio / dotnet run use launchSettings.json to control URLs and environment.
Create or update Properties/launchSettings.json:
{
"profiles": {
"EntraIdDemo.Api": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
If you want Swagger too, add this to Program.cs before var app = builder.Build();:
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
And this after var app = builder.Build();:
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
Run the API:
dotnet run
You should see it listening on https://localhost:5001 (Dev certificate).
6. Entra ID Setup: App Registration, Scopes, and App Roles
Now we map the code to real Entra ID config.
6.1. Register the API
In the Azure Portal:
- Go to Microsoft Entra ID → App registrations → New registration
- Name:
EntraIdDemo.Api - Supported account types: usually “Accounts in this organizational directory only”
- Redirect URI: not required for pure Web APIs
- Click Register
Copy:
-
Application (client) ID →
AzureAd:ClientId -
Directory (tenant) ID →
AzureAd:TenantId
Update your appsettings*.json accordingly.
6.2. Expose an API → Application ID URI & Scope
In your app registration:
- Go to Expose an API
- Click Set for Application ID URI
Use:
api://{YOUR_API_CLIENT_ID} - Click Add a scope:
- Scope name:
Api.Read - Scope display name:
Read access to demo API - Scope URI becomes:
api://{YOUR_API_CLIENT_ID}/Api.Read - Save
- Scope name:
This scope is what clients will request when asking for tokens.
6.3. Create App Roles (for /api/admin-only)
Still in the same app registration:
- Go to App roles
- Click Create app role
- Fill in:
- Display name:
Api Admin - Allowed member types:
Users/Groups - Value:
Api.Admin - Description:
Administrator role for demo API - Enabled:
true
- Display name:
- Save
Now assign the role to a user or group:
- Go to Enterprise applications
- Find your
EntraIdDemo.Apiservice principal - Go to Users and groups
- Add assignment:
- Select user or group
- Select
Api Adminrole
Tokens for that user will now contain:
"roles": [
"Api.Admin"
]
And your .RequireAuthorization("RequireApiAdmin") will work as expected.
7. Testing with Postman (or Any OAuth2 Client)
You have multiple choices for testing:
- Another app registration as a confidential client (server-to-server)
- A SPA using MSAL.js
- Postman using OAuth 2.0 / Authorization Code
Conceptually, each client will:
- Request a token with scope:
api://{YOUR_API_CLIENT_ID}/Api.Read - Receive an access token (JWT)
- Call your API with:
Authorization: Bearer {access_token}
Example call:
GET https://localhost:5001/api/secure
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs...
If the token is valid and audience/scope match, you’ll see a JSON payload with UserName, ObjectId, Roles, and Scopes.
To hit /api/admin-only:
- The caller’s principal must have the
Api.Adminrole assigned. - The token must include
"roles": ["Api.Admin"].
8. Publishing to Azure App Service
Once it works locally, push it to the cloud.
8.1. Create an App Service
From the Azure Portal or CLI, create:
- A Resource Group
- An App Service Plan
- An App Service (Linux or Windows, .NET 8)
8.2. Publish the API
You can deploy with:
- Visual Studio “Publish”
-
dotnet publish+ ZIP deploy - GitHub Actions / Azure DevOps
The API code does not change — just configuration.
8.3. Configure Application Settings
In the App Service → Configuration → Application settings, add:
AzureAd__Instance = https://login.microsoftonline.com/AzureAd__TenantId = <YOUR_TENANT_ID>AzureAd__ClientId = <YOUR_API_APP_CLIENT_ID>AzureAd__Domain = yourtenant.onmicrosoft.comAzureAd__Audience = api://<YOUR_API_APP_CLIENT_ID>
Save & restart the App Service.
Now you can call:
GET https://YOUR_APP_SERVICE.azurewebsites.net/api/secure
Authorization: Bearer {access_token_for_Api.Read}
Same tokens, new base URL.
9. Common Pitfalls and How to Avoid Them
9.1. 401 Unauthorized with “audience” errors
Symptom:
- Token is valid, but API returns 401
- Logs mention invalid audience /
audmismatch
Fix:
- Ensure
AzureAd:Audiencematches exactly the Application ID URI (api://{CLIENT_ID}) - If you changed Application ID URI, reissue tokens
9.2. Tokens missing roles claim
Symptom:
-
/api/secureworks -
/api/admin-onlyreturns 403 (Forbidden) - JWT payload does not include
"roles"
Fix:
- Confirm you created an App role with value
Api.Admin - Assign that role to the user or group in Enterprise applications → Users and groups
- Sign out and sign in again to refresh tokens
9.3. Using the wrong tenant
Symptom:
- Login works, but tokens are not accepted
- Configured
TenantIddoes not match where the app is registered
Fix:
- Verify
TenantIdinappsettings.jsonand Azure App Service settings - Make sure the token is issued by that tenant (
issclaim)
9.4. Forgetting HTTPS in production
Symptom:
- Everything works locally, but you accidentally expose
http://in production
Fix:
- Always enforce HTTPS with App Service and your API
- Keep
app.UseHttpsRedirection();enabled
10. Production Checklist
Before calling this “done”, walk through this:
- [ ] API registration created in Microsoft Entra ID
- [ ] Application ID URI set to
api://{CLIENT_ID} - [ ] At least one scope defined, e.g.
Api.Read - [ ] Optional: App role
Api.Admincreated and assigned as needed - [ ]
.NET 8Web API wired with:- [ ]
AddMicrosoftIdentityWebApi - [ ]
UseAuthentication()andUseAuthorization() - [ ] Endpoints annotated with
.RequireAuthorization()and/or policies
- [ ]
- [ ] Configuration is externalized:
- [ ]
TenantId,ClientId,Audiencestored in secrets/app settings
- [ ]
- [ ] Tokens tested locally using:
- [ ] Postman, or
- [ ] Test client app (MSAL)
- [ ] Deployed to Azure App Service with proper
AzureAd__*settings - [ ] Logging in place for rejected tokens (optional but recommended)
Once you’re comfortable with this “hello world” setup, you can incrementally add:
-
Scopes for different operations (
Api.Read,Api.Write) -
Multiple roles (
Api.Admin,Api.Reader,Api.Billing) - On-behalf-of flows if you’re calling downstream APIs
- Managed Identity for accessing Key Vault, Storage, etc.
The important thing is: you now understand the core path from Entra ID config → token → claims → authorization in a real .NET 8 Web API.
Happy securing — and may your 401s always be intentional. 🔐

Top comments (0)