DEV Community

Bilal Haidar
Bilal Haidar

Posted on

Securing Your APIs with Microsoft Entra ID: The Pattern That Works for Any Stack

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

  1. Go to Entra admin center β†’ App registrations β†’ New registration.
  2. Name it something like orders-api. Don't add a redirect URI, because APIs don't sign users in.
  3. 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.
  4. (Recommended) Under Manifest, set "requestedAccessTokenVersion": 2 so the API receives v2 tokens.

πŸ”§ Step 2: Register the Client

  1. Create a new registration called orders-spa.
  2. 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.
  3. Go to API permissions β†’ Add a permission β†’ My APIs β†’ orders-api, choose Delegated, and tick access_as_user.
  4. 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
Enter fullscreen mode Exit fullscreen mode

appsettings.json

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "<tenant-id>",
    "ClientId": "<api-client-id>",
    "Audience": "api://<api-client-id>"
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

πŸ–₯️ 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();
Enter fullscreen mode Exit fullscreen mode

appsettings.json

{
  "OrdersApi": {
    "BaseUrl": "https://localhost:7001",
    "Scopes": [ "api://<api-client-id>/access_as_user" ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Then inject IDownstreamApi and call:

var orders = await downstreamApi.GetForUserAsync<OrdersResponse>(
    "OrdersApi", o => o.RelativePath = "orders");
Enter fullscreen mode Exit fullscreen mode

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

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 Audience doesn'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)