Use the Microsoft Entra on-behalf-of (OBO) flow when an authenticated client calls a middle ASP.NET Core API and that API must call another API as the same user. The middle API validates the incoming token, exchanges it for a new delegated token whose audience is the downstream API, and sends that second token only to its intended resource.

This guide refreshes the original November 2024 .NET 8 example around that end-to-end job. The historical GitHub repository, portal captures, and result video remain available. The configuration and code below are source-grounded guidance, not a claim that the 2024 repository was rebuilt or tested unchanged against later packages.

Understand the OBO security boundary

An OBO chain uses two access tokens. Token A is issued for the middle API, so its aud claim must identify that API. The middle API uses Token A as the user assertion in a confidential token request. Microsoft Entra ID then issues Token B for the downstream API. Token B keeps delegated user context, but it has its own audience and permissions.

Historical Microsoft Entra on-behalf-of token flow diagram
The original 2024 diagram: the client calls the middle API with Token A; the middle API obtains Token B for the downstream API.

Do not relay Token A to the downstream API. Its audience is wrong, and forwarding it breaks the resource boundary that access tokens are designed to enforce. Do not use OBO for an app-only request either: when no user principal exists, the middle service needs the Microsoft Entra client credentials flow.

CallToken audiencePermission type
Client → Customer APICustomer APIDelegated scope, for example access_as_user
Customer API → Order APIOrder APIDelegated scope, for example orders.read

Register the three applications

The sample architecture needs separate registrations for the interactive client, the middle Customer API, and the downstream Order API. Separation makes each audience and consent relationship explicit.

  1. Order API: expose a delegated scope such as orders.read.
  2. Customer API: expose access_as_user, add delegated permission to Order API’s orders.read, and configure a confidential credential.
  3. Interactive client: add delegated permission to Customer API’s access_as_user. Use the correct interactive flow for that client type.

The middle API is both a resource server and a confidential client. It validates Token A as a resource, then authenticates itself when requesting Token B. Prefer a certificate or workload identity where the hosting environment supports it. If a client secret is retained for the historical .NET 8 example, keep it outside source control and rotate it through a managed secret store.

Consent must cover both legs of the chain. A tenant administrator can grant the downstream delegated permission to the middle API in advance. Without consent, the middle API has no interactive screen of its own and token acquisition can fail with a claims or consent challenge.

Configure the middle ASP.NET Core API

The middle API needs bearer-token validation, authorization, token acquisition, a token cache, and a configured downstream API client. The following configuration uses placeholders intentionally. It does not contain a real tenant, application ID, or credential.

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "YOUR_TENANT_ID",
    "ClientId": "YOUR_CUSTOMER_API_CLIENT_ID",
    "Audience": "api://YOUR_CUSTOMER_API_CLIENT_ID",
    "ClientCredentials": [
      {
        "SourceType": "ClientSecret",
        "ClientSecret": "LOAD_FROM_A_SECRET_PROVIDER"
      }
    ]
  },
  "DownstreamApis": {
    "OrderApi": {
      "BaseUrl": "https://localhost:7202/",
      "Scopes": [
        "api://YOUR_ORDER_API_CLIENT_ID/orders.read"
      ]
    }
  }
}

AzureAd describes the middle API registration and its confidential credential. OrderApi names the downstream resource, base URL, and delegated scope. Replace the secret placeholder with configuration from your deployment platform; never commit a real secret to appsettings.json.

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(
        builder.Configuration.GetSection("AzureAd"))
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddInMemoryTokenCaches();

builder.Services.AddAuthorization();
builder.Services.AddControllers();
builder.Services.AddDownstreamApis(
    builder.Configuration.GetSection("DownstreamApis"));

var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

The middleware order matters: authentication must populate the user before authorization evaluates policies. The in-memory token cache is acceptable for a local single-instance walkthrough. A scaled deployment needs a cache design that fits its topology and protects token data; do not assume an in-memory cache will survive restarts or work coherently across instances.

Validate the incoming delegated permission

Successful JWT validation proves who issued Token A, who it targets, and whether it is still valid. It does not automatically prove that the caller has permission to execute a particular operation. Require the delegated scope at the Customer API endpoint.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Web;

[Authorize]
[ApiController]
[Route("api/customers")]
public sealed class CustomersController : ControllerBase
{
    [HttpGet("me")]
    [RequiredScope("access_as_user")]
    public IActionResult GetCurrentCustomer()
    {
        return Ok(new { status = "Customer token accepted" });
    }
}

RequiredScope checks the delegated permission in the token rather than treating every authenticated principal as equally authorized. Keep the scope narrow and aligned with the operation. If an endpoint also accepts app-only callers, model that as a deliberate second authorization path and validate application roles separately.

Call the downstream API for the user

IDownstreamApi lets Microsoft.Identity.Web acquire the downstream token from the current user context and attach it to the request. The controller should not parse, log, or manually forward the inbound bearer token.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Web;

[Authorize]
[ApiController]
[Route("api/customers")]
public sealed class CustomerOrdersController : ControllerBase
{
    private readonly IDownstreamApi _downstreamApi;

    public CustomerOrdersController(IDownstreamApi downstreamApi)
    {
        _downstreamApi = downstreamApi;
    }

    [HttpGet("me/orders")]
    [RequiredScope("access_as_user")]
    public async Task<ActionResult<IReadOnlyList<OrderDto>>> GetOrders(
        CancellationToken cancellationToken)
    {
        var orders = await _downstreamApi.GetForUserAsync<
            IReadOnlyList<OrderDto>>(
                "OrderApi",
                "api/orders/me",
                cancellationToken: cancellationToken);

        return Ok(orders ?? Array.Empty<OrderDto>());
    }
}

public sealed record OrderDto(Guid Id, decimal Total, string Status);

The name OrderApi must match the configuration section. The method requests the configured delegated scope and sends Token B to the relative path. Pass the request cancellation token so abandoned client requests do not keep consuming downstream capacity.

Protect the downstream API

Order API validates Token B against its own registration. Its audience must be the Order API, not Customer API. It must also require the downstream delegated scope.

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();
builder.Services.AddControllers();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();
[Authorize]
[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
    [HttpGet("me")]
    [RequiredScope("orders.read")]
    public ActionResult<IReadOnlyList<OrderDto>> GetMyOrders()
    {
        // Query only records the current delegated user may access.
        return Ok(Array.Empty<OrderDto>());
    }
}

The empty result is a placeholder for application-specific data access, not a finished authorization model. The downstream service must map the user claims it trusts to tenant and record-level rules. A valid orders.read scope does not by itself prove that the user owns every order.

Verify the complete token chain

Verification should prove both successful routing and the failure boundaries. Decode development tokens only in a safe local tool and never paste production bearer tokens into logs or public websites.

  1. Acquire Token A for Customer API’s access_as_user scope through the interactive client.
  2. Call GET /api/customers/me/orders with Token A. Expect the middle API to obtain Token B and return the downstream result.
  3. Inspect non-sensitive claims locally. Token A’s aud must be Customer API; Token B’s aud must be Order API. Both should represent the same delegated user.
  4. Remove consent for orders.read or request an ungranted scope. Expect token acquisition to fail; do not silently fall back to an app-only identity.
  5. Send Token A directly to Order API. Expect rejection because the audience is wrong.
  6. Remove orders.read from Token B. Expect authorization failure at the downstream endpoint.

This test sequence distinguishes authentication, token exchange, audience validation, and operation authorization. A single HTTP 200 does not prove all four controls.

Handle production failures safely

SymptomLikely boundaryCheck
401 at Customer APIToken A validationIssuer, audience, signature, expiry, authentication middleware
403 at Customer APIIncoming authorizationaccess_as_user and endpoint policy
Consent or claims challengeOBO acquisitionDelegated Order API permission, admin consent, Conditional Access
401 at Order APIToken B validationOrder API audience and registration configuration
403 at Order APIDownstream authorizationorders.read, tenant, and record-level access

Return a controlled 401 or 403 response instead of exposing identity-provider exception details. Log a correlation ID, the failing stage, and non-secret metadata, but never log access tokens, authorization headers, client secrets, or personally sensitive claims.

Conditional Access can require the interactive client to satisfy an additional claim. The middle API cannot display a sign-in screen, so it must propagate the claims challenge to a client that can complete interaction. Treat this as part of the client/API contract, not as a generic retry condition.

  • Use a certificate or managed credential rather than a long-lived client secret when practical.
  • Use a distributed token cache when multiple API instances must share token state.
  • Set explicit downstream timeouts and cancellation; retry only safe, transient operations.
  • Preserve least privilege on both scopes and apply tenant or record authorization after token validation.
  • Keep server clocks synchronized and monitor token-acquisition failures separately from downstream HTTP failures.
  • Do not start ordinary background work with a request token and assume the user context will remain valid indefinitely.

Historical demo and source code

The following video records the original .NET 8 project and Microsoft Entra configuration from November 2024. It demonstrates that historical environment; it is not evidence that the repository was rerun unchanged today.

Original Microsoft Entra on-behalf-of flow demonstration recorded in 2024.

The complete historical implementation remains in the On-Behalf-Of Flow Entra ID repository. Treat old tenant IDs, screenshots, package versions, and portal labels as historical. Recreate registrations with new identifiers and credentials; never restore an expired secret from an old commit or screenshot. For the client-side leg of the chain, see Microsoft Entra authorization code flow with PKCE in .NET.

References

Found this useful? Support more practical developer content.

Author

Practical .NET, Angular, Azure, Blazor, and AI engineering for real-world development.

Ads Blocker Image Powered by Code Help Pro

Ads Blocker Detected!!!

We have detected that you are using extensions to block ads. Please support us by disabling these ads blocker.

Powered By
Best Wordpress Adblock Detecting Plugin | CHP Adblock