Authentication with Entra ID, OAuth 2.0, and JWT
These three pieces fit together as follows:
- Microsoft Entra ID is the identity provider (IdP). It authenticates users and apps and issues tokens.
- OAuth 2.0 is the authorization framework: how a client gets permission to call an API on behalf of a user or as itself.
- OpenID Connect (OIDC) is a layer on OAuth 2.0 for sign-in. It adds an ID token that tells your app who the user is.
- JWT is the token format. Entra issues access tokens and ID tokens as signed JSON Web Tokens.
Access tokens are for APIs ("this caller may do X"). ID tokens are for the client app ("this user signed in"). Never send an ID token to an API.
1. Choose the flow
| Scenario | Flow |
|---|---|
| SPA or mobile app, user signs in, calls your API | Authorization code + PKCE |
| Server-side web app with user sign-in | Authorization code (with a client secret or certificate) |
| Service-to-service, no user (daemon, background job) | Client credentials |
| API calls another API as the signed-in user | On-behalf-of (OBO) |
Avoid the implicit flow and the password (ROPC) flow. Both are legacy and discouraged.
2. Register the apps in Entra ID
You typically register two apps: one for the API and one for the client.
API registration:
- Go to Entra ID → App registrations → New registration.
- Under Expose an API, set the Application ID URI (e.g.
api://<api-client-id>) and add a scope such asaccess_as_userfor delegated (user) permissions. - Under App roles, add roles such as
Orders.Readfor app-only callers (and for user roles). - In the manifest, make sure
requestedAccessTokenVersionis2so you get v2.0 tokens.
Client registration:
- Add a redirect URI. Use the SPA platform for SPAs, which enables PKCE, or Web for server apps.
- Under API permissions, add your API's scope or role and grant admin consent if needed.
- For confidential clients, add a certificate (preferred) or client secret under Certificates & secrets.
3. Protect an ASP.NET Core API (validate JWTs)
dotnet add package Microsoft.Identity.Web
appsettings.json:
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "<tenant-id>",
"ClientId": "<api-client-id>",
"Audience": "api://<api-client-id>"
}
}
Program.cs:
using Microsoft.Identity.Web;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("OrdersRead", p => p.RequireRole("Orders.Read"));
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Controller:
[Authorize]
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
// Delegated (user) token: check the scope claim
[HttpGet]
[RequiredScope("access_as_user")]
public IActionResult GetMine() => Ok(new { user = User.Identity?.Name });
// App-only token (client credentials): check the roles claim
[HttpGet("all")]
[Authorize(Policy = "OrdersRead")]
public IActionResult GetAll() => Ok();
}
User tokens carry scopes in the scp claim, and app-only tokens carry app roles in the roles claim. Check the right one for the flow you expect.
4. Sign users in to a web app (OIDC)
builder.Services
.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"))
.EnableTokenAcquisitionToCallDownstreamApi(new[] { "api://<api-client-id>/access_as_user" })
.AddInMemoryTokenCaches();
Inject ITokenAcquisition to get an access token for the signed-in user and call your API:
var token = await _tokenAcquisition.GetAccessTokenForUserAsync(
new[] { "api://<api-client-id>/access_as_user" });
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
For a SPA, use MSAL.js (@azure/msal-browser, or @azure/msal-react / @azure/msal-angular). It handles the code + PKCE flow, token caching, and silent refresh:
const msal = new PublicClientApplication({
auth: { clientId: "<spa-client-id>", authority: "https://login.microsoftonline.com/<tenant-id>" }
});
const result = await msal.acquireTokenPopup({
scopes: ["api://<api-client-id>/access_as_user"]
});
fetch("https://my-api/api/orders", {
headers: { Authorization: `Bearer ${result.accessToken}` }
});
5. Service-to-service (client credentials)
When there's no user, the caller authenticates as itself. Always request the .default scope, which returns the app roles granted to that client:
var credential = new DefaultAzureCredential(); // managed identity in Azure
var token = await credential.GetTokenAsync(
new TokenRequestContext(new[] { "api://<api-client-id>/.default" }));
Prefer a managed identity over a client secret. Assign the API's app role to the managed identity's service principal (via Graph, PowerShell, or Azure CLI). Outside Azure, use ConfidentialClientApplication from Microsoft.Identity.Client with a certificate.
6. What your API must validate in a JWT
A JWT has three base64url parts, header.payload.signature. AddMicrosoftIdentityWebApi does the following for you, but you should know what's being checked:
- Signature: verified using Entra's public signing keys, fetched from the OIDC metadata endpoint (
.../.well-known/openid-configuration→jwks_uri) and cached and rotated automatically. iss(issuer): must be your tenant, e.g.https://login.microsoftonline.com/<tenant-id>/v2.0.aud(audience): must be your API (its client ID or Application ID URI). A token meant for another API must be rejected.exp/nbf: token is within its validity window (access tokens last roughly 60 to 90 minutes).scporroles: the caller has the permission required for this operation.tid: for multi-tenant APIs, check that the tenant is one you allow.
Key claims you'll use: oid (user or service principal object ID), sub, tid, scp, roles, preferred_username, appid/azp (the calling client).
If you ever need manual configuration without Microsoft.Identity.Web:
.AddJwtBearer(o =>
{
o.Authority = "https://login.microsoftonline.com/<tenant-id>/v2.0";
o.TokenValidationParameters = new()
{
ValidAudience = "api://<api-client-id>",
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true
};
});
7. Common pitfalls
- Mixing up token types: APIs accept access tokens only, never ID tokens.
- Wrong audience or token version: a v1 token has a different
issandaudformat than v2, so set the API's accepted token version to match your validation. - Checking only authentication: a valid token isn't enough. Always check scopes or roles, otherwise any app in your tenant that can get a token for your API is authorized.
- Storing tokens in
localStorage: this exposes them to XSS. Let MSAL manage caching, or use a backend-for-frontend (BFF) with HttpOnly cookies. - Client secrets in code or SPAs: public clients (SPAs, mobile) can't keep secrets, which is why they use PKCE.
- Decoding without validating: reading claims from a JWT without verifying its signature proves nothing.
- Forgetting admin consent for application permissions, which causes 403s.
- Rolling your own JWT handling: use MSAL and Microsoft.Identity.Web rather than hand-parsing tokens and keys.
8. Alternative: let Azure handle it (Easy Auth)
On App Service or Azure Functions, built-in authentication validates the token before your code runs and passes claims to your app via headers (X-MS-CLIENT-PRINCIPAL). It's the least-code option, though you get less control over fine-grained authorization than with middleware in your own code.
Summary
- Register an API (with scopes and app roles) and a client in Entra ID.
- Use auth code + PKCE for users and client credentials (ideally with a managed identity) for services.
- The client acquires an access token via MSAL and sends it as
Authorization: Bearer <token>. - The API validates the JWT (signature, issuer, audience, expiry) and enforces scopes/roles.