ASP.NET Core delegated Microsoft OBO access token management (Entra only)

When you build a chain of APIs behind Microsoft Entra ID, sooner or later you hit a problem that is not really about authentication, it is about token handoff. A user logs in to a web app, that web app calls an API, and that API needs to call another API on behalf of the same user. Microsoft has a proprietary grant for exactly this situation, called the On-Behalf-Of flow, or OBO. This article walks through a working ASP.NET Core setup that implements OBO end to end using the Microsoft.Identity.Web package, and looks closely at how access token management should be handled in that kind of chain.

What the On-Behalf-Of flow actually does

OBO lets a middle-tier API exchange the access token it received from a caller for a new access token, scoped to a downstream API, while keeping the original user’s identity in the new token. It looks similar to the standard OAuth 2.0 token exchange grant described in RFC 8693, but it is not that standard. It is a Microsoft-specific flavor that only works when every party in the chain is registered in the same Entra tenant setup. If you need to hand off tokens to an API protected by a different identity provider, such as OpenIddict or Duende IdentityServer, you need the actual OAuth token exchange grant instead, not OBO.

Microsoft.Identity.Web hides most of the plumbing for this. Once you wire up EnableTokenAcquisitionToCallDownstreamApi and a distributed token cache, the library handles requesting, caching, and refreshing the OBO token for you. The catch is that this convenience only holds as long as every hop in the chain uses Entra ID and the Microsoft.Identity.Web abstractions consistently. Mix in a different library or provider partway through, and you end up writing the token exchange by hand.

The three-application setup

The sample uses three applications chained together: a Razor Pages web UI, a first Web API that receives the user’s delegated token and performs the OBO exchange, and a second downstream Web API that only validates the resulting JWT. The web UI authenticates the user with OpenID Connect and gets a delegated access token for the first API. The first API takes that token, exchanges it On-Behalf-Of the user for a new token scoped to the second API, and calls it. The second API has no UI and no OBO logic at all, it just validates the incoming JWT like any protected API would.

Web UI, first API, and downstream API chained together using the Microsoft On-Behalf-Of flow
Web UI, first API, and downstream API chained together using the Microsoft On-Behalf-Of flow

What good token management actually requires

It is easy to get an OBO demo working and still ship something that breaks in production. Damien Bod lists the properties a real token management solution needs to satisfy, and they are worth internalizing before you copy any of the code below.

  • The access token is persisted per user session, not globally
  • The token expires and that expiry must be respected, not ignored
  • The token needs to be persisted safely, encrypted at rest if it is not purely in-memory
  • The token must be replaced after every UI authentication or refresh for that user
  • The solution must keep working after an application restart
  • The solution must work correctly when the app is scaled out to multiple instances
  • Invalid or missing access tokens must be handled explicitly, not left to throw unhandled exceptions
  • A user logout must actually clear the cached tokens for that user

Most of these fall out of the box once you use AddDistributedTokenCaches with a real distributed cache such as Redis or SQL Server, instead of AddDistributedMemoryCache, which the sample uses for local development only. In-memory caching will pass every test on your laptop and then fail the moment you deploy to more than one instance, because each instance has its own cache and a user’s token exists on only one of them.

Step 1: The web UI acquires the first delegated token

The web UI is a Razor Pages and Blazor Server hybrid that authenticates the user against Entra ID using the authorization code flow with PKCE, as a confidential client. Registration looks like this in Program.cs.

builder.Services.AddHttpClient();
 
builder.Services.AddOptions();
 
string[]? initialScopes = builder.Configuration
	.GetValue<string>("WebApiEntraId:ScopeForAccessToken")?
	.Split(' ');
 
builder.Services.AddDistributedMemoryCache();
builder.Services
	.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration,
		"EntraID",
        subscribeToOpenIdConnectMiddlewareDiagnosticsEvents: true)
    .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
    .AddDistributedTokenCaches();
 
builder.Services
    .AddAuthorization(options =>
    {
        options.FallbackPolicy = options.DefaultPolicy;
    });
 
builder.Services.AddRazorPages()
    .AddMvcOptions(options =>
    {
        var policy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
        options.Filters.Add(new AuthorizeFilter(policy));
    }).AddMicrosoftIdentityUI();
 
builder.Services.AddServerSideBlazor()
    .AddMicrosoftIdentityConsentHandler();

The important call here is EnableTokenAcquisitionToCallDownstreamApi, chained straight after AddMicrosoftIdentityWebAppAuthentication. It tells Microsoft.Identity.Web that this app is not just authenticating a user, it also wants access tokens for a downstream API using that same authenticated identity. AddDistributedTokenCaches then stores those tokens outside process memory, which is what makes token persistence survive an app restart or a scale-out event. FallbackPolicy set to DefaultPolicy means every page requires authentication unless you explicitly allow anonymous access, which is a sensible default for an internal line-of-business app.

Once the app has a valid session, a typed service pulls the cached access token and calls the first API. This is the part that actually uses the token day to day.

using Microsoft.Extensions.Configuration;
using Microsoft.Identity.Web;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Threading.Tasks;
 
namespace RazorPageEntraId.WebApiEntraId;
 
public class WebApiEntraIdService
{
    private readonly IHttpClientFactory _clientFactory;
    private readonly ITokenAcquisition _tokenAcquisition;
    private readonly IConfiguration _configuration;
 
    public WebApiEntraIdService(IHttpClientFactory clientFactory,
        ITokenAcquisition tokenAcquisition,
        IConfiguration configuration)
    {
        _clientFactory = clientFactory;
        _tokenAcquisition = tokenAcquisition;
        _configuration = configuration;
    }
 
    public async Task<string?> GetWebApiEntraIdDataAsync()
    {
        var client = _clientFactory.CreateClient();
 
        var scope = _configuration["WebApiEntraID:ScopeForAccessToken"];
        var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync([scope!]);
 
        client.BaseAddress = new Uri(_configuration["WebApiEntraID:ApiBaseAddress"]!);
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
 
        var response = await client.GetAsync("/api/profiles/photo");
        if (response.IsSuccessStatusCode)
        {
            var responseContent = await response.Content.ReadFromJsonAsync<string>();
 
            return responseContent;
        }
 
        throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
    }
}

GetAccessTokenForUserAsync is doing more work than the name suggests. It first checks the distributed cache for a still-valid token for the signed-in user and the requested scope, and only calls Entra ID for a fresh token if the cached one is missing or expired. You never write cache lookup or refresh logic yourself, which is exactly the point of using Microsoft.Identity.Web instead of hand-rolling MSAL calls. One thing to watch for in production is that this method throws if the user’s session cookie is present but the underlying refresh token has become invalid, for example after a password reset, so wrap the call in a try block and redirect to re-authentication rather than letting a 500 reach the user.

Step 2: The first API performs the actual OBO exchange

This is where the On-Behalf-Of flow happens. The first API receives the delegated token issued for it, and instead of just trusting that token and answering the request, it exchanges that token for a new one scoped to the second, downstream API. Registration is short because Microsoft.Identity.Web already knows how to do an OBO exchange once you enable token acquisition on the API side.

builder.Services.AddTransient<WebApiDownstreamService>();
builder.Services.AddHttpClient();
builder.Services.AddOptions();
 
builder.Services.AddDistributedMemoryCache();
 
builder.Services
	.AddMicrosoftIdentityWebApiAuthentication(
		builder.Configuration, "EntraID")
	.EnableTokenAcquisitionToCallDownstreamApi()
	.AddDistributedTokenCaches();

AddMicrosoftIdentityWebApiAuthentication configures this project as a resource server that validates incoming JWTs. Chaining EnableTokenAcquisitionToCallDownstreamApi onto it is what turns on OBO support specifically for an API project, as opposed to the web app version used earlier. The actual exchange happens inside GetAccessTokenForUserAsync again, but this time it is called from within an API that is itself acting on a token it received, not one it issued through a login flow.

using Microsoft.Identity.Web;
using System.Net.Http.Headers;
using System.Text.Json;
 
namespace WebApiEntraIdObo.WebApiEntraId;
 
public class WebApiDownstreamService
{
    private readonly IHttpClientFactory _clientFactory;
    private readonly ITokenAcquisition _tokenAcquisition;
    private readonly IConfiguration _configuration;
 
    public WebApiDownstreamService(IHttpClientFactory clientFactory,
        ITokenAcquisition tokenAcquisition,
        IConfiguration configuration)
    {
        _clientFactory = clientFactory;
        _tokenAcquisition = tokenAcquisition;
        _configuration = configuration;
    }
 
    public async Task<string?> GetApiDataAsync()
    {
        var client = _clientFactory.CreateClient();
 
        // user_impersonation access_as_user access_as_application .default
        var scope = _configuration["WebApiEntraIdObo:ScopeForAccessToken"];
        if (scope == null) throw new ArgumentNullException(nameof(scope));
 
        var uri = _configuration["WebApiEntraIdObo:ApiBaseAddress"];
        if (uri == null) throw new ArgumentNullException(nameof(uri));
 
        var accessToken = await _tokenAcquisition
            .GetAccessTokenForUserAsync([scope]);
 
        client.DefaultRequestHeaders.Authorization
            = new AuthenticationHeaderValue("Bearer", accessToken);
 
        client.BaseAddress = new Uri(uri);
        client.DefaultRequestHeaders.Accept.Add(
			new MediaTypeWithQualityHeaderValue("application/json"));
 
        var response = await client.GetAsync("api/profiles/photo");
        if (response.IsSuccessStatusCode)
        {
            var data = await JsonSerializer.DeserializeAsync<string>(
                await response.Content.ReadAsStreamAsync());
 
            return data;
        }
 
        throw new ApplicationException($"Status code: {response.StatusCode}, "
			+ $"Error: {response.ReasonPhrase}");
    }
}

Note the comment above the scope line, it lists the different scope shapes you can end up requesting, such as user_impersonation, access_as_user, or the .default scope for pure client credential calls. Getting the scope string wrong is probably the single most common mistake with OBO, Entra will return an invalid_grant or an interaction_required error and the message rarely tells you plainly that the scope is the problem. Double check the scope matches exactly what is exposed on the downstream API’s app registration, including casing.

Step 3: The downstream API just validates the JWT

The last API in the chain does not know or care that OBO happened upstream. It only validates that the bearer token it received is a well-formed JWT issued by the expected Entra tenant, for the expected audience.

builder.Services.AddControllers(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        // .RequireClaim("email") // disabled this to test with users that have no email (no license added)
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
});
 
builder.Services.AddHttpClient();
builder.Services.AddOptions();
 
builder.Services.AddMicrosoftIdentityWebApiAuthentication(
    builder.Configuration, "EntraID");

This is standard JWT bearer validation, nothing OBO-specific about it. The commented-out RequireClaim(“email”) line is a useful real-world reminder, some Entra ID tenant users, particularly guest accounts or accounts without an assigned license, do not carry an email claim in the token. If you require that claim by default, you will lock out legitimate users for reasons that have nothing to do with security, so only add claim requirements you have actually confirmed are always present for your user population.

Running the three applications together

With all three projects running locally and registered correctly in Entra ID, signing in through the web UI results in the downstream API’s data flowing all the way back up the chain and rendering in the browser.

The web UI displaying data retrieved through the chained OBO calls
The web UI displaying data retrieved through the chained OBO calls

If this last step fails, the fault is almost always one of three things: the API-to-API scope in app registration is missing the exposed permission, the client app has not been granted admin consent for that permission, or the distributed cache configuration differs between what was tested locally and what is deployed. Checking the Entra ID sign-in logs for the specific error code saves far more time than staring at exception stack traces in the app.

When OBO is the right choice, and when it is not

OBO fits cleanly when every application in the chain is registered in the same Entra tenant and you are not going to add a non-Microsoft identity provider anywhere along the path. It keeps the user’s identity intact end to end without you having to build your own token exchange logic, and Microsoft.Identity.Web takes care of caching so you are not reinventing that either.

Where it gets awkward is multi-hop chains, an API calling another API which itself needs to call Graph or a third API on behalf of the same user. Each hop needs its own OBO exchange and its own cache entry, and debugging which hop lost the user’s context becomes harder as the chain grows. If you are building that kind of deep chain, it is worth instrumenting each hop with structured logging that includes the object ID of the user and the scope requested, so you can trace a single request across all three or four applications instead of guessing.

If any part of your chain talks to an identity provider other than Entra ID, for example an internal API protected by OpenIddict or Duende IdentityServer, OBO will not work there because it is a Microsoft-only grant. Damien Bod’s related posts cover that exact scenario using the standard OAuth 2.0 token exchange grant from RFC 8693 instead, which is the correct fallback when your downstream API sits outside the Microsoft identity ecosystem.

Production considerations

Switch AddDistributedMemoryCache to a real distributed cache before you deploy anywhere with more than one instance. Redis is the common choice, and Microsoft.Identity.Web’s distributed token cache works with it without extra code beyond registering the Redis cache provider in place of the in-memory one.

Use a certificate for client assertions instead of a client secret once you move past development. The sample uses a secret for simplicity, but Microsoft explicitly recommends certificates or federated credentials for production app registrations, since secrets are easier to leak through configuration files or environment variable dumps and need periodic rotation that certificates handle more gracefully.

Handle token acquisition failures explicitly at every hop. A user whose refresh token has been revoked, whether through a password change, a conditional access policy, or an admin action, will cause GetAccessTokenForUserAsync to throw. Catching that specific failure and redirecting to re-authentication is a better experience than letting it bubble up as an unhandled 500 error.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading