This guide explains how to configure the On-Behalf-Of (OBO) flow to call Microsoft Graph on behalf of authenticated users from an Azure Functions MCP server.
The OBO flow allows your Azure Function to:
- Accept an authenticated user's token via Easy Auth
- Exchange that token for a new token scoped to Microsoft Graph
- Call Graph APIs on behalf of the user
┌─────────┐ ┌───────────────┐ ┌─────────────────────┐ ┌───────────────┐
│ User │────▶│ MCP Client │────▶│ Azure Function │────▶│ Microsoft │
│ │ │ (VS Code) │ │ + Easy Auth │ │ Graph API │
└─────────┘ └───────────────┘ └─────────────────────┘ └───────────────┘
│ │
│ Bearer Token │ OBO Token Exchange
│ (user assertion) │ (via managed identity)
└───────────────────────┘
- Azure subscription
- Azure CLI
- Azure Developer CLI (azd)
- .NET 10 SDK
azd upThis creates:
- Azure Function App
- User-assigned Managed Identity
- App registration (via Easy Auth)
- Go to Azure Portal → App registrations → [Your App]
- Click API permissions → Add a permission
- Select Microsoft Graph → Delegated permissions
- Add User.Read
- (Optional) Click Grant admin consent if available
- Go to Expose an API
- Set Application ID URI to
api://{client-id}(if not already set) - Add a scope:
- Scope name:
access_as_user - Who can consent: Admins and users
- Admin consent display name: Access as user
- Admin consent description: Allows the app to access resources on behalf of the signed-in user
- Scope name:
- Go to Authentication
- Add a Web platform redirect URI:
https://{your-function-app}.azurewebsites.net/.auth/login/aad/callback - Under Implicit grant and hybrid flows, check:
- ✅ ID tokens
- Click Save
This allows the managed identity to authenticate to Entra ID as the app registration.
# Get the managed identity object ID
MI_OBJECT_ID=$(az identity show \
--resource-group {rg-name} \
--name {mi-name} \
--query principalId -o tsv)
# Create federated credential
az ad app federated-credential create \
--id {app-client-id} \
--parameters '{
"name": "managed-identity-federation",
"issuer": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"subject": "'$MI_OBJECT_ID'",
"audiences": ["api://AzureADTokenExchange"],
"description": "Federated credential for managed identity OBO flow"
}'az webapp auth config-version upgrade \
--resource-group {rg-name} \
--name {function-app-name}
az webapp auth microsoft update \
--resource-group {rg-name} \
--name {function-app-name} \
--client-id {app-client-id} \
--issuer "https://login.microsoftonline.com/{tenant-id}/v2.0" \
--allowed-audiences "api://{app-client-id}" "{app-client-id}" \
--yesThis is critical - Easy Auth must request Graph scopes during login so the user consents to them.
# Get subscription ID
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
# Get current auth settings
az rest --method GET \
--uri "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/{rg-name}/providers/Microsoft.Web/sites/{function-app-name}/config/authsettingsV2?api-version=2023-12-01" \
> /tmp/auth.json
# Add loginParameters with Graph scopes
cat /tmp/auth.json | jq '.properties.identityProviders.azureActiveDirectory.login += {"loginParameters": ["scope=openid profile email https://graph.microsoft.com/User.Read"]}' > /tmp/auth_updated.json
# Apply updated settings
az rest --method PUT \
--uri "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/{rg-name}/providers/Microsoft.Web/sites/{function-app-name}/config/authsettingsV2?api-version=2023-12-01" \
--body @/tmp/auth_updated.jsonThe following environment variables are needed:
| Setting | Source | Description |
|---|---|---|
AZURE_TENANT_ID |
Bicep output | Your Entra ID tenant ID |
WEBSITE_AUTH_CLIENT_ID |
Auto-injected by Easy Auth | App registration client ID |
OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID |
Bicep | User-assigned managed identity client ID |
Note: WEBSITE_AUTH_CLIENT_ID is automatically set by Easy Auth when authentication is enabled - no manual configuration needed. AZURE_TENANT_ID is already output by main.bicep. OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID is already configured in api.bicep.
To avoid consent prompts for MCP clients like VS Code:
- Go to App registrations → [Your App] → Expose an API
- Under Authorized client applications, click Add a client application
- Add VS Code's client ID:
04b07795-8ddb-461a-bbee-02f9e1bf7b46 - Select your
access_as_userscope
public class HelloTool(ILogger<HelloTool> logger, IHostEnvironment hostEnvironment)
{
private static readonly string[] GraphScopes = ["https://graph.microsoft.com/.default"];
[Function(nameof(HelloTool))]
public async Task<string> Run(
[McpToolTrigger(nameof(HelloTool), "Responds to the user with a hello message.")]
ToolInvocationContext context)
{
TokenCredential credential;
if (hostEnvironment.IsDevelopment())
{
// Use local developer credentials
credential = new ChainedTokenCredential(
new AzureCliCredential(),
new VisualStudioCodeCredential());
}
else
{
// Use OBO flow in production
credential = BuildOnBehalfOfCredential(context);
}
using var graphClient = new GraphServiceClient(credential, GraphScopes);
var me = await graphClient.Me.GetAsync();
return $"Hello, {me?.DisplayName} ({me?.Mail})!";
}
private static TokenCredential BuildOnBehalfOfCredential(ToolInvocationContext context)
{
if (!context.TryGetHttpTransport(out var transport))
throw new InvalidOperationException("No HTTP transport available.");
// Get user token from Easy Auth or Authorization header
var userToken = GetUserToken(transport!);
// Get configuration from environment variables
// AZURE_TENANT_ID - output by bicep
// WEBSITE_AUTH_CLIENT_ID - auto-injected by Easy Auth
string tenantId = Environment.GetEnvironmentVariable("AZURE_TENANT_ID")
?? throw new InvalidOperationException("AZURE_TENANT_ID is not set.");
string clientId = Environment.GetEnvironmentVariable("WEBSITE_AUTH_CLIENT_ID")
?? throw new InvalidOperationException("WEBSITE_AUTH_CLIENT_ID is not set.");
// Build client assertion callback using managed identity federation
var clientAssertionCallback = BuildClientAssertionCallback();
return new OnBehalfOfCredential(tenantId, clientId, clientAssertionCallback, userToken);
}
private static string GetUserToken(HttpTransport transport)
{
// Try Easy Auth token store first
if (transport.Headers.TryGetValue("X-MS-TOKEN-AAD-ACCESS-TOKEN", out var easyAuthToken)
&& !string.IsNullOrEmpty(easyAuthToken))
return easyAuthToken;
// Fallback to Authorization header (for MCP clients sending Bearer tokens directly)
if (transport.Headers.TryGetValue("Authorization", out var authHeader)
&& authHeader.StartsWith("Bearer "))
return authHeader["Bearer ".Length..];
throw new InvalidOperationException("No access token found.");
}
private static Func<CancellationToken, Task<string>> BuildClientAssertionCallback()
{
// OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID - set by bicep
string miClientId = Environment.GetEnvironmentVariable("OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID")
?? throw new InvalidOperationException("OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID is not set.");
var managedIdentity = new ManagedIdentityCredential(miClientId);
string audience = Environment.GetEnvironmentVariable("TokenExchangeAudience")
?? "api://AzureADTokenExchange";
return async (cancellationToken) =>
{
var token = await managedIdentity.GetTokenAsync(
new TokenRequestContext([$"{audience}/.default"]),
cancellationToken);
return token.Token;
};
}
}<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="Microsoft.Graph" Version="5.94.0" />After configuring everything, trigger consent by visiting:
https://{your-function-app}.azurewebsites.net/.auth/login/aad?prompt=consent
# After login, get your token
curl -s https://{your-function-app}.azurewebsites.net/.auth/me | jq '.access_token'Configure .vscode/mcp.json:
{
"servers": {
"my-mcp-server": {
"type": "http",
"url": "https://{your-function-app}.azurewebsites.net/runtime/webhooks/mcp",
"headers": {
"Authorization": "Bearer {your-token}"
}
}
}
}Cause: The user hasn't consented to Graph permissions.
Fix:
- Ensure
loginParametersincludesscope=... https://graph.microsoft.com/User.Read - Have the user re-authenticate with
?prompt=consent - Or grant admin consent in Azure Portal
Cause: ID token issuance is not enabled for the app.
Fix: Go to App registration → Authentication → Enable ID tokens
Cause: The redirect URI is missing.
Fix: Add https://{function-app}/.auth/login/aad/callback to redirect URIs
Cause: Easy Auth is not enabled or configured correctly.
Fix: Verify Easy Auth is enabled and authentication is required
Cause: Federated credential not configured correctly.
Fix:
- Verify federated credential exists on the app registration
- Check the subject matches the managed identity object ID
- Ensure the managed identity is assigned to the Function App
-
Token validation: Easy Auth validates incoming tokens. Additional validation may be needed for sensitive operations.
-
Scope limitations: Only request the minimum Graph scopes needed.
-
Token caching: Consider implementing token caching to reduce OBO exchanges.
-
Audit logging: Log authentication events for security monitoring.