Whether your backend is .NET, Laravel, Node or Python, the Entra ID setup is the same: one app registration for the API and one for each client. The API publishes a permission (a scope), and the client asks for it. Here's the full flow.
π§ The Mental Model
-
The API app says: "Here's a permission called
access_as_user. Anyone calling me needs a token that includes it." - The client app (React, mobile, MVC or another API) says: "I'd like to call that API on behalf of the signed-in user."
-
Entra ID signs the user in, asks for consent and issues an access token for the API's audience with
scp: access_as_user. - The API checks the token's signature, issuer, audience and scope. It never deals with passwords.
π§ Step 1: Register the API
- Go to Entra admin center β App registrations β New registration.
- Name it something like
orders-api. Don't add a redirect URI, because APIs don't sign users in. - Go to Expose an API:
- Set the Application ID URI. The default is
api://<api-client-id>. - Select Add a scope:
-
Scope name:
access_as_user - Who can consent: Admins and users
- Fill in the display name and description.
-
Scope name:
- Set the Application ID URI. The default is
-
(Recommended) Under Manifest, set
"requestedAccessTokenVersion": 2so the API receives v2 tokens.
π§ Step 2: Register the Client
- Create a new registration called
orders-spa. - Go to Authentication β Add a platform:
- For React, choose Single-page application with redirect
http://localhost:5173. - For a .NET web app, choose Web with redirect
https://localhost:5001/signin-oidc, then add a client secret or certificate.
- For React, choose Single-page application with redirect
- Go to API permissions β Add a permission β My APIs β orders-api, choose Delegated, and tick
access_as_user. - Select Grant admin consent so users don't see a consent prompt.
π‘ Optional: In the API's Expose an API page, add the client ID under Authorized client applications to pre-authorize it.
π‘οΈ Step 3: Protect the .NET Core API
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.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/orders", (HttpContext ctx) =>
new { user = ctx.User.Identity?.Name, orders = new[] { "A-100", "A-101" } })
.RequireAuthorization()
.RequireScope("access_as_user");
app.Run();
If you use controllers, add [Authorize] and [RequiredScope("access_as_user")] instead.
βοΈ Step 4: Call the API from React (MSAL)
npm i @azure/msal-browser @azure/msal-react
authConfig.js
export const msalConfig = {
auth: {
clientId: "<spa-client-id>",
authority: "https://login.microsoftonline.com/<tenant-id>",
redirectUri: "http://localhost:5173",
},
cache: { cacheLocation: "sessionStorage" },
};
export const apiScopes = ["api://<api-client-id>/access_as_user"];
main.jsx
import ReactDOM from "react-dom/client";
import { PublicClientApplication } from "@azure/msal-browser";
import { MsalProvider } from "@azure/msal-react";
import { msalConfig } from "./authConfig";
import App from "./App";
const pca = new PublicClientApplication(msalConfig);
await pca.initialize();
ReactDOM.createRoot(document.getElementById("root")).render(
<MsalProvider instance={pca}>
<App />
</MsalProvider>
);
Orders.jsx: calling the API
import { useMsal } from "@azure/msal-react";
import { InteractionRequiredAuthError } from "@azure/msal-browser";
import { apiScopes } from "./authConfig";
export function Orders() {
const { instance, accounts } = useMsal();
const load = async () => {
if (!accounts.length) return instance.loginRedirect({ scopes: apiScopes });
try {
const { accessToken } = await instance.acquireTokenSilent({
scopes: apiScopes,
account: accounts[0],
});
const res = await fetch("https://localhost:7001/orders", {
headers: { Authorization: `Bearer ${accessToken}` },
});
console.log(await res.json());
} catch (e) {
if (e instanceof InteractionRequiredAuthError) {
instance.acquireTokenRedirect({ scopes: apiScopes });
}
}
};
return <button onClick={load}>Load orders</button>;
}
π₯οΈ Bonus: A .NET Web App as the Client
Program.cs
builder.Services
.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"))
.EnableTokenAcquisitionToCallDownstreamApi()
.AddDownstreamApi("OrdersApi", builder.Configuration.GetSection("OrdersApi"))
.AddInMemoryTokenCaches();
appsettings.json
{
"OrdersApi": {
"BaseUrl": "https://localhost:7001",
"Scopes": [ "api://<api-client-id>/access_as_user" ]
}
}
Then inject IDownstreamApi and call:
var orders = await downstreamApi.GetForUserAsync<OrdersResponse>(
"OrdersApi", o => o.RelativePath = "orders");
π Other Stacks (Laravel, Node, Pythonβ¦)
The Entra setup doesn't change. On the API side, validate the JWT against:
https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys
Then check these claims:
| Claim | Must match |
|---|---|
aud |
Your API's client ID or Application ID URI |
iss |
Your tenant's issuer |
scp |
Contains access_as_user
|
β οΈ Common Pitfalls
-
401 with "invalid audience": The client requested the wrong scope, or the API's
Audiencedoesn't match. - Sending the ID token to the API: ID tokens are for the client. APIs only accept access tokens.
- AADSTS65001 (consent required): Grant admin consent or pre-authorize the client.
- Putting secrets in SPAs: Don't. SPAs are public clients and rely on PKCE.
β TL;DR
Each API exposes a scope, each client requests that scope, and the API validates the access token. Every stack follows this same pattern.
Top comments (0)