This article shows how to:
- Register your API in Microsoft Entra (Azure AD) and expose a scope.
- Register clients (daemon and web client) and grant permissions.
- Protect a .NET Web API (ASP.NET Core) using
Microsoft.Identity.Web
. - Call the API from:
- a daemon/service using client credentials
- a backend that performs On-Behalf-Of (OBO) to call a downstream API
Replace placeholders like
YOUR_TENANT_ID
,API_CLIENT_ID
,CLIENT_APP_ID
, andCLIENT_SECRET
with actual values from the portal.
✳️ Portal steps (brief)
-
Register API (
MyProductApi
)- Azure Portal → Microsoft Entra ID → App registrations → New registration →
MyProductApi
- Note: Application (client) ID →
API_CLIENT_ID
, Directory (tenant) ID →YOUR_TENANT_ID
- Azure Portal → Microsoft Entra ID → App registrations → New registration →
-
Expose an API
- App → Expose an API → Set Application ID URI to
api://{API_CLIENT_ID}
(default) orhttps://contoso.com/my-api
- Add a scope:
- Scope name:
access_as_user
- Admin consent display name:
Access MyProductApi as user
- Who can consent:
Admins and users
- Description:
Allow the app to access MyProductApi on your behalf.
- Scope name:
- App → Expose an API → Set Application ID URI to
-
Register Client 1 (Daemon) —
MyDaemonClient
- New registration → note Client ID
DAEMON_CLIENT_ID
- Certificates & secrets → create Client secret →
DAEMON_CLIENT_SECRET
- API permissions → Add permission → MyProductApi → Application permissions → pick the app-permission you added (or use
.default
on token call) - Grant admin consent (tenant admin)
- New registration → note Client ID
-
Register Client 2 (Web App) —
MyWebClient
- New registration → note Client ID
WEB_CLIENT_ID
- Configure platform (Web) with Redirect URI if you will do interactive auth.
- API permissions → Add permission → MyProductApi → Delegated permissions →
access_as_user
- (No admin consent if user consent allowed; otherwise admin consent)
- New registration → note Client ID
1) Protected ASP.NET Core Web API — MyProductApi
Project: MyProductApi
(ASP.NET Core 7+)
Add packages
dotnet add package Microsoft.Identity.Web
dotnet add package Microsoft.Identity.Web.MicrosoftGraph # optional if calling MS Graph later
appsettings.json
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "YOUR_TENANT_ID",
"ClientId": "API_CLIENT_ID",
"Audience": "api://API_CLIENT_ID"
},
"Logging": { "LogLevel": { "Default": "Information" } }
}
Program.cs
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.Resource;
var builder = WebApplication.CreateBuilder(args);
// Add Microsoft.Identity.Web (validates incoming JWT access tokens)
builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration.GetSection("AzureAd"));
// Optional: easier scope validation helper
string[] scopeRequiredByApi = new string[] { "access_as_user" };
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
ProductsController.cs (validate scope inside action)
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Web.Resource;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
// Use ResourceValidation helpers (Microsoft.Identity.Web.Resource)
private static readonly string[] scopeRequired = new string[] { "access_as_user" };
[HttpGet]
public IActionResult Get()
{
// Will throw 403 if scope not present
HttpContext.VerifyUserHasAnyAcceptedScope(scopeRequired);
var products = new[] {
new { Id = 1, Name = "Product A" },
new { Id = 2, Name = "Product B" }
};
return Ok(products);
}
}
Notes
VerifyUserHasAnyAcceptedScope
readsscp
claim in token and returns 403 if missing.Audience
/ClientId
must match tokenaud
(token audience). If you used custom Application ID URI, ensureaud
matches that value.
2) Example A — Daemon / Service: Client Credentials flow (no user)
This example demonstrates a background service calling the API using client credentials. This flow uses application permissions (app-only).
Daemon console app (or any service)
Add NuGet
dotnet add package Microsoft.Identity.Client
Program.cs
using System.Net.Http.Headers;
using Microsoft.Identity.Client;
string tenantId = "YOUR_TENANT_ID";
string clientId = "DAEMON_CLIENT_ID";
string clientSecret = "DAEMON_CLIENT_SECRET"; // store securely in Key Vault in production
string apiScope = "api://API_CLIENT_ID/.default"; // use .default to request all app permissions
IConfidentialClientApplication cca = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithClientSecret(clientSecret)
.WithAuthority($"https://login.microsoftonline.com/{tenantId}")
.Build();
var result = await cca.AcquireTokenForClient(new string[] { apiScope }).ExecuteAsync();
Console.WriteLine("Access token acquired");
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken);
var resp = await http.GetAsync("https://localhost:5001/api/products");
if (resp.IsSuccessStatusCode)
{
var content = await resp.Content.ReadAsStringAsync();
Console.WriteLine(content);
}
else
{
Console.WriteLine($"Call failed: {resp.StatusCode} - {await resp.Content.ReadAsStringAsync()}");
}
Important
- The API must support app-only calls (check
roles
or accept tokens with noscp
). If you designed the API only for delegated scopes, add application roles in the API registration and verifyroles
claim in the API if required.
3) Example B — On-Behalf-Of (Frontend → API A → Downstream API B)
Scenario:
MyWebClient
obtains a token forMyProductApi
with scopeapi://API_CLIENT_ID/access_as_user
.- Frontend calls
MyProductApi
passing that token. MyProductApi
needs to callDownstreamApi
(e.g., another API or Microsoft Graph) on behalf of the signed-in user, using OBO.
Prerequisites
MyWebClient
requests scope forMyProductApi
.MyProductApi
registration must be configured as a confidential client (have a client secret or certificate).MyProductApi
must be granted permission to call the downstream API (as delegated permission) in the portal.
Add packages
dotnet add package Microsoft.Identity.Web
dotnet add package Microsoft.Identity.Web.MicrosoftGraph // optional
appsettings.json (for MyProductApi)
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "YOUR_TENANT_ID",
"ClientId": "API_CLIENT_ID",
"ClientSecret": "API_CLIENT_SECRET", // secret for OBO; use Key Vault in production
"Domain": "yourtenant.onmicrosoft.com"
}
}
Program.cs (register token acquisition service)
using Microsoft.Identity.Web;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration.GetSection("AzureAd"));
// Register ITokenAcquisition to use AcquireTokenOnBehalfOf
builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration.GetSection("AzureAd"));
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
ProductsController.cs (OBO call)
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Web;
using System.Net.Http.Headers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly ITokenAcquisition _tokenAcquisition;
private readonly HttpClient _http;
public ProductsController(ITokenAcquisition tokenAcquisition, IHttpClientFactory httpFactory)
{
_tokenAcquisition = tokenAcquisition;
_http = httpFactory.CreateClient();
}
[HttpGet("with-downstream")]
public async Task<IActionResult> GetProductsWithDownstreamCall()
{
// Verify caller has required scope
HttpContext.VerifyUserHasAnyAcceptedScope(new[] { "access_as_user" });
// Scopes for downstream API (example)
string[] scopesForDownstream = new[] { "api://DOWNSTREAM_API_CLIENT_ID/access_as_user" };
// Acquire token on behalf of the user (OBO)
string accessTokenForDownstream = await _tokenAcquisition.GetAccessTokenForUserAsync(scopesForDownstream);
// Call downstream API
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessTokenForDownstream);
var response = await _http.GetAsync("https://downstream-api.example.com/api/values");
var downstreamContent = await response.Content.ReadAsStringAsync();
return Ok(new
{
products = new[] { "Product A", "Product B" },
downstream = new { status = response.StatusCode, content = downstreamContent }
});
}
}
Notes
ITokenAcquisition
(from Microsoft.Identity.Web) handles the OBO exchange. It uses the incoming user assertion (the access token sent toMyProductApi
) and your API's confidential client credentials to obtain a new token for the downstream API.- The downstream API must trust tokens where
aud
matches its Application ID URI.
Troubleshooting checklist (quick)
- Token
aud
mismatch → check Application ID URI and what the client requested. - Missing
scp
→ ensure client requestedapi://API_CLIENT_ID/access_as_user
. - OBO fails with
AADSTS70011
or 401 → ensure API is configured with client secret/cert and the incoming token is an access token (not an id_token). 403
from API → token does not contain required scope/role orVerifyUserHasAnyAcceptedScope
blocked it. Inspect token with jwt.ms to confirmscp
,roles
,aud
,iss
.- Use
Azure AD sign-in logs
for troubleshooting authentication failures.
Security best practices (short)
- Store secrets in Key Vault and use Managed Identity where possible.
- Use certificates (preferred) instead of client secrets for confidential clients.
- Validate scopes/roles on every request.
- Apply least privilege to scopes and permissions.
- Monitor and rotate credentials regularly.
Comments
Post a Comment