DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

Microsoft Entra ID + .NET 8 Web API — From Zero to Production-Ready Authentication

Microsoft Entra ID + .NET 8 Web API — From Zero to Production-Ready Authentication

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

  1. Mental Model: What You Actually Need from Entra ID
  2. Project Setup: .NET 8 Web API + Microsoft.Identity.Web
  3. Configuration: appsettings.json for AzureAd
  4. Program.cs: Authentication, Authorization, and Test Endpoints
  5. Local Execution: launchSettings and dotnet run
  6. Entra ID Setup: App Registration, Scopes, and App Roles
  7. Testing with Postman (or Any OAuth2 Client)
  8. Publishing to Azure App Service
  9. Common Pitfalls and How to Avoid Them
  10. 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:

  1. An App Registration representing your API
  2. One or more scopes that clients request (e.g. api://{CLIENT_ID}/Api.Read)
  3. 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

💡 Version numbers change. In real life you can just run:

dotnet add package Microsoft.Identity.Web
dotnet add package Microsoft.Identity.Web.MicrosoftGraph

and 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": "*"
}
Enter fullscreen mode Exit fullscreen mode
  • 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.Admin role
  • Three endpoints:
    • /api/public — no auth
    • /api/secure — any valid token
    • /api/admin-only — requires role Api.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();
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you want Swagger too, add this to Program.cs before var app = builder.Build();:

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
Enter fullscreen mode Exit fullscreen mode

And this after var app = builder.Build();:

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
Enter fullscreen mode Exit fullscreen mode

Run the API:

dotnet run
Enter fullscreen mode Exit fullscreen mode

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:

  1. Go to Microsoft Entra IDApp registrationsNew registration
  2. Name: EntraIdDemo.Api
  3. Supported account types: usually “Accounts in this organizational directory only”
  4. Redirect URI: not required for pure Web APIs
  5. Click Register

Copy:

  • Application (client) IDAzureAd:ClientId
  • Directory (tenant) IDAzureAd:TenantId

Update your appsettings*.json accordingly.

6.2. Expose an API → Application ID URI & Scope

In your app registration:

  1. Go to Expose an API
  2. Click Set for Application ID URI Use: api://{YOUR_API_CLIENT_ID}
  3. 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

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:

  1. Go to App roles
  2. Click Create app role
  3. Fill in:
    • Display name: Api Admin
    • Allowed member types: Users/Groups
    • Value: Api.Admin
    • Description: Administrator role for demo API
    • Enabled: true
  4. Save

Now assign the role to a user or group:

  1. Go to Enterprise applications
  2. Find your EntraIdDemo.Api service principal
  3. Go to Users and groups
  4. Add assignment:
    • Select user or group
    • Select Api Admin role

Tokens for that user will now contain:

"roles": [
  "Api.Admin"
]
Enter fullscreen mode Exit fullscreen mode

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:

  1. Request a token with scope: api://{YOUR_API_CLIENT_ID}/Api.Read
  2. Receive an access token (JWT)
  3. Call your API with: Authorization: Bearer {access_token}

Example call:

GET https://localhost:5001/api/secure
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs...
Enter fullscreen mode Exit fullscreen mode

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.Admin role 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 → ConfigurationApplication settings, add:

  • AzureAd__Instance = https://login.microsoftonline.com/
  • AzureAd__TenantId = <YOUR_TENANT_ID>
  • AzureAd__ClientId = <YOUR_API_APP_CLIENT_ID>
  • AzureAd__Domain = yourtenant.onmicrosoft.com
  • AzureAd__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}
Enter fullscreen mode Exit fullscreen mode

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 / aud mismatch

Fix:

  • Ensure AzureAd:Audience matches exactly the Application ID URI (api://{CLIENT_ID})
  • If you changed Application ID URI, reissue tokens

9.2. Tokens missing roles claim

Symptom:

  • /api/secure works
  • /api/admin-only returns 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 TenantId does not match where the app is registered

Fix:

  • Verify TenantId in appsettings.json and Azure App Service settings
  • Make sure the token is issued by that tenant (iss claim)

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.Admin created and assigned as needed
  • [ ] .NET 8 Web API wired with:
    • [ ] AddMicrosoftIdentityWebApi
    • [ ] UseAuthentication() and UseAuthorization()
    • [ ] Endpoints annotated with .RequireAuthorization() and/or policies
  • [ ] Configuration is externalized:
    • [ ] TenantId, ClientId, Audience stored 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)