Working with Azure API Management across multiple tenants? Tired of managing service principal secrets and certificates? In this blog post, I’m going to show you how to set up passwordless cross-tenant authentication using User-Assigned Managed Identities and Federated Credentials.
This approach eliminates the need for client secrets while enabling secure service-to-service authentication between API Management instances in different Entra ID tenants. Let me walk you through exactly how I implemented this pattern, from the architecture to the actual APIM policies.
The Problem: Cross-Tenant API Access
Here’s the scenario: You have an API Management instance in tenant1 (your application tenant) that needs to call protected APIs hosted in tenant2 (perhaps a partner or different business unit).
The traditional approach? Create a service principal, generate a client secret, store it securely, rotate it regularly, and hope nobody leaks it. Not ideal.
What if I told you there’s a way to do this completely passwordless using managed identities and federated credentials? Let’s look at how.
The Solution: Federated Credentials
Federated Identity Credentials allow a managed identity in one tenant to authenticate as an application in another tenant – without any secrets. Think of it as a trust relationship where tenant2 says “I trust this specific managed identity from tenant1 to act as this application.”
Here’s what makes this powerful:
- No secrets to manage – The managed identity uses its Azure-provided certificate
- Cross-tenant support – Works seamlessly across Entra ID tenants
- OAuth 2.0 compliant – Uses standard client credentials flow with JWT bearer assertions
- Role-based access – Uses app roles for granular authorisation
This is aligned with Microsoft’s latest guidance: use federated credentials over shared secrets for stronger security and reduced operational overhead.
Architecture Overview
Before we jump into the setup, let’s understand the moving parts involved:
Tenant1 (Caller/Consumer)
- APIM Instance – Makes the API calls
- User-Assigned Managed Identity (UAMI) – Attached to APIM for authentication
- Multi-tenant Bridge App – App registration that enables cross-tenant auth
Tenant2 (Provider/API Owner)
- APIM Instance – Hosts the protected APIs
- tenant1 Bridge App Service Principal (tenant1 SP) – The caller’s identity in tenant2
- tenant2 API App – App registration that defines the protected resource and app roles
- tenant2 API Service Principal (tenant2 API SP) – Service principal that owns the app roles (created in Step 6)
Understanding the Two Service Principals in tenant2
This is a common point of confusion, so let me break it down:
tenant1 SP (Bridge App Service Principal)
- Created in tenant2 via
az ad sp create --id $BRIDGE_APP_ID - Represents who is making the request (the caller)
- Gets assigned the
access_as_approle - Appears in the token as the
oidclaim - Think of it as: “This is the identity of the caller in tenant2”
tenant2 API SP (API App Service Principal)
- Created in tenant2 via
az ad sp create --id $TENANT2_API_APPID - Represents what resource the token is for (the protected API)
- Owns the
access_as_app role definition - Its App ID appears in the token as the
audclaim - Think of it as: “This is the protected resource with defined permissions”
The Role Assignment Connection
- tenant1 SP (the caller) is granted the role → “has access_as_app role”
- tenant2 API SP (the resource) owns the role → “defines access_as_app role”
- This follows OAuth 2.0 pattern: audience (`aud`) must match the resource, not the authentication mechanism
The authentication flow uses a two-step token exchange:
1. tenant1 UAMI gets an assertion token from tenant1
2. Exchange that assertion for a final access token from tenant2 (with app roles)
3. tenant2 APIM validates the token and allows access

Step-by-Step Setup
Alright, lets get into the actual setup! I’m going to walk through each step with the exact commands you need.
Prerequisites
You’ll need:
- Owner/Contributor access to both tenant1 and tenant2
- Azure CLI installed and logged in
- Two APIM instances (one in each tenant)
Step 0: Set Your Variables
First, let’s set up some variables to make the commands easier. Replace these with your actual values:
# tenant1 Variables
TENANT1_ID="your-tenant1-id"
TENANT1_SUBSCRIPTION="your-tenant1-subscription"
TENANT1_APIM_NAME="tenant1-apim"
TENANT1_RG="tenant1-rg"
UAMI_NAME="tenant1-uami"
# tenant2 Variables
TENANT2_ID="your-tenant2-id"
TENANT2_SUBSCRIPTION="your-tenant2-subscription"
TENANT2_APIM_NAME="tenant2-apim"
TENANT2_API_APP_NAME="tenant2-api-app"
Step 1: Create the User-Assigned Managed Identity (tenant1)
In tenant1, create a UAMI (or use an existing one):
az login --tenant $TENANT1_ID
az account set --subscription $TENANT1_SUBSCRIPTION
# Create UAMI if it doesn't exist
az identity show -g $TENANT1_RG -n $UAMI_NAME --query clientId -o tsv >/dev/null 2>&1 || \
az identity create -g $TENANT1_RG -n $UAMI_NAME -o none
# Get UAMI details
UAMI_JSON=$(az identity show -g $TENANT1_RG -n $UAMI_NAME -o json)
UAMI_CLIENT_ID=$(echo "$UAMI_JSON" | jq -r '.clientId')
UAMI_PRINCIPAL_ID=$(echo "$UAMI_JSON" | jq -r '.principalId')
UAMI_RESOURCE_ID=$(echo "$UAMI_JSON" | jq -r '.id')
echo "UAMI Client ID: $UAMI_CLIENT_ID"
echo "UAMI Principal ID (Object ID): $UAMI_PRINCIPAL_ID"
Expected Output:
UAMI Client ID: XXXXXXXXX-XXXX-XXX-XXX-XXX-XXXXX
UAMI Principal ID (Object ID): XXXXXXXXX-XXXX-XXX-XXX-XXX-XXXXX
Step 2: Attach UAMI to tenant1 APIM
az login --tenant $TENANT1_ID
az account set --subscription $TENANT1_SUBSCRIPTION
# Get APIM resource ID
APIM_RESOURCE_ID=$(az resource show \
-g $TENANT1_RG \
-n $TENANT1_APIM_NAME \
--resource-type "Microsoft.ApiManagement/service" \
--query id -o tsv)
# Attach UAMI to APIM
az resource update --ids "$APIM_RESOURCE_ID" \
--set identity.type="UserAssigned" \
--set identity.userAssignedIdentities."$UAMI_RESOURCE_ID"={} -o none
echo "Attached UAMI to APIM"
Step 3: Create Multi-Tenant Bridge App (tenant1)
This app enables the cross-tenant authentication:
az login --tenant $TENANT1_ID
az account set --subscription $TENANT1_SUBSCRIPTION
# Create multi-tenant app registration
BRIDGE_APP_NAME="tenant1-bridge-app"
az ad app create \
--display-name $BRIDGE_APP_NAME \
--sign-in-audience AzureADMultipleOrgs
BRIDGE_APP_ID=$(az ad app list --display-name $BRIDGE_APP_NAME --query [0].appId -o tsv)
BRIDGE_APP_OBJECTID=$(az ad app list --display-name $BRIDGE_APP_NAME --query [0].id -o tsv)
echo "Bridge App ID (Client ID): $BRIDGE_APP_ID"
echo "Bridge App Object ID: $BRIDGE_APP_OBJECTID"
Important: Save both IDs – you’ll need the App ID for Step 4 and the Object ID for Step 5.
Step 4: Create Service Principal in tenant2
Now switch to tenant2 and create a service principal for the bridge app. This creates the caller’s identity in tenant2:
az login --tenant $TENANT2_ID
az account set --subscription $TENANT2_SUBSCRIPTION
# Create service principal for tenant1's bridge app in tenant2
TENANT1_SP_JSON=$(az ad sp create --id $BRIDGE_APP_ID -o json)
TENANT1_SP_OBJECTID=$(echo "$TENANT1_SP_JSON" | jq -r '.id')
echo "Created service principal in tenant2"
echo "tenant1 SP Object ID in tenant2: $TENANT1_SP_OBJECTID"
Expected output:
Created service principal in tenant2
tenant1 SP Object ID in tenant2: XXXXXXXXX-XXXX-XXX-XXX-XXX-XXXXX
What just happened? You’ve created a service principal in tenant2 that represents the tenant1 bridge app. This is the caller’s identity in tenant2.
Step 5: Configure Federated Credential (tenant1)
Back in tenant1, create the federated credential that links the UAMI to the bridge app. This is critical – the configuration must match the token claims exactly:
az login --tenant $TENANT1_ID
# Get the bridge app Object ID (not the App ID!)
BRIDGE_APP_OBJECTID=$(az ad app show --id $BRIDGE_APP_ID --query id -o tsv)
FED_CRED_NAME="APIM-UAMI-FederatedCred"
# Create federated credential using Microsoft Graph API
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/applications/$BRIDGE_APP_OBJECTID/federatedIdentityCredentials" \
--headers "Content-Type=application/json" \
--body "{
\"name\": \"$FED_CRED_NAME\",
\"issuer\": \"https://login.microsoftonline.com/$TENANT1_ID/v2.0\",
\"subject\": \"$UAMI_PRINCIPAL_ID\",
\"description\": \"Allows APIM UAMI to request tokens for tenant2 API\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"
echo "Created federated credential"
Critical Configuration Notes:
- issuer: Must be
https://login.microsoftonline.com/{TENANT1_ID}/v2.0(note the `/v2.0` at the end) - subject: Must be the UAMI Principal ID (Object ID), NOT the Client ID
- audiences: Standard value for federated credentials is
["api://AzureADTokenExchange"]
Verify the federated credential was created:
az ad app federated-credential list --id $BRIDGE_APP_ID
You should see output like:
[
{
"audiences": ["api://AzureADTokenExchange"],
"description": "Allows APIM UAMI to request tokens for tenant2 API",
"issuer": "https://login.microsoftonline.com/{TENANT1_ID}/v2.0",
"name": "APIM-UAMI-FederatedCred",
"subject": "{UAMI_PRINCIPAL_ID}"
}
]
Step 6: Create tenant2 API App with App Roles (tenant2)
In tenant2, create the API app that represents your protected resource. This is a **separate app** from the bridge app and follows OAuth 2.0 best practices:
Why a separate tenant2 API app?
- Separation of Concerns: Authentication app (bridge) vs API resource (tenant2 API) have different purposes
- OAuth 2.0 Compliance: Follows the resource server pattern where token
audmatches the protected resource - Scalability: Multiple consumers can be granted different roles on the same API
- Fine-grained RBAC: Define custom app roles specific to your API’s needs
az login --tenant $TENANT2_ID
az account set --subscription $TENANT2_SUBSCRIPTION
# Create the tenant2 API app registration
TENANT2_API_APP_NAME="tenant2-api-app"
TENANT2_API_JSON=$(az ad app create \
--display-name $TENANT2_API_APP_NAME \
--sign-in-audience "AzureADMyOrg" \
-o json)
TENANT2_API_APPID=$(echo "$TENANT2_API_JSON" | jq -r '.appId')
TENANT2_API_OBJECTID=$(echo "$TENANT2_API_JSON" | jq -r '.id')
echo "Created tenant2 API app"
echo "tenant2 API App ID: $TENANT2_API_APPID"
echo "tenant2 API Object ID: $TENANT2_API_OBJECTID"
# Set identifier URI for the API
az ad app update --id $TENANT2_API_APPID --identifier-uris "api://$TENANT2_API_APPID"
# Define app role
APP_ROLE_ID=$(uuidgen)
APP_ROLE_VALUE="access_as_app"
# Add app role to tenant2 API app
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/applications/$TENANT2_API_OBJECTID" \
--headers "Content-Type=application/json" \
--body "{
\"appRoles\": [{
\"allowedMemberTypes\": [\"Application\"],
\"description\": \"Allow apps to call tenant2 API\",
\"displayName\": \"Access tenant2 API\",
\"id\": \"$APP_ROLE_ID\",
\"isEnabled\": true,
\"value\": \"$APP_ROLE_VALUE\"
}]
}"
echo "Added app role to tenant2 API app"
# Create service principal for the tenant2 API app
TENANT2_API_SP_JSON=$(az ad sp create --id $TENANT2_API_APPID -o json)
TENANT2_API_SP_OBJECTID=$(echo "$TENANT2_API_SP_JSON" | jq -r '.id')
echo "Created service principal for tenant2 API app"
echo "tenant2 API SP Object ID: $TENANT2_API_SP_OBJECTID"
Important: You now have TWO service principals in tenant2:
1. tenant1 SP(from Step 4) – The caller’s identity
2. tenant2 API SP (just created) – The resource owner that defines the app roles
Note about app roles: Unlike Microsoft APIs (Graph, Azure RM), custom APIs must define their own app roles. This is by design and allows you to create permissions specific to your API’s needs.
Step 7: Assign App Role to tenant1 SP
Now comes the key authorisation step! Grant the tenant1 SP (the caller) the access_as_app role on the tenant2 API SP (the resource). This is still in tenant2:
# Still in tenant2 - assign app role to tenant1 SP
# This grants the tenant1 APIM permission to call the tenant2 API
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$TENANT1_SP_OBJECTID/appRoleAssignments" \
--headers "Content-Type=application/json" \
--body "{
\"principalId\": \"$TENANT1_SP_OBJECTID\",
\"resourceId\": \"$TENANT2_API_SP_OBJECTID\",
\"appRoleId\": \"$APP_ROLE_ID\"
}"
echo "Assigned '$APP_ROLE_VALUE' role to tenant1 service principal"
What just happened?
- principalId: tenant1 SP (the caller – “who”) – from Step 4
- resourceId: tenant2 API SP (the resource – “what”) – from Step 6
- appRoleId: The
access_as_approle defined on tenant2 API app
This role assignment is what puts the roles: ["access_as_app"] claim in your access token!
Important Notes:
- This step can be repeated for additional consumers with different
principalIdvalues - Each consumer can be granted different roles for fine-grained access control
- The tenant2 APIM policy will validate this role claim
Awesome! You’ve now set up all the Entra ID components. Let’s move on to configuring the APIM policies.
Policies Setup Explained
This is where the magic happens. The APIM policies implement the two-step token exchange.
tenant1 APIM Policy (Inbound)
This policy performs the two-step token exchange and forwards the request to tenant2 APIM:
<policies>
<inbound>
<!-- Set backend URL and rewrite path -->
<set-backend-service base-url="https://YOUR_TENANT2_APIM_NAME.azure-api.net" />
<rewrite-uri template="/your-api-path/validate" copy-unmatched-params="true" />
<!-- Step 1: Get assertion token from UAMI for the tenant1 multi-tenant app -->
<send-request mode="new" response-variable-name="dummyResponse" timeout="20" ignore-error="true">
<set-url>https://management.azure.com/</set-url>
<set-method>GET</set-method>
<authentication-managed-identity
resource="api://AzureADTokenExchange"
client-id="YOUR_UAMI_CLIENT_ID"
output-token-variable-name="assertionToken" />
</send-request>
<!-- Step 2: Exchange assertion token for tenant2 API token using federated credential -->
<send-request mode="new" response-variable-name="finalTokenResponse" timeout="20" ignore-error="false">
<set-url>https://login.microsoftonline.com/YOUR_TENANT2_ID/oauth2/v2.0/token</set-url>
<set-method>POST</set-method>
<set-header name="Content-Type" exists-action="override">
<value>application/x-www-form-urlencoded</value>
</set-header>
<set-body>@{
var bridgeAppId = "YOUR_BRIDGE_APP_ID";
var tenant2ApiAppId = "YOUR_TENANT2_API_APPID";
return $"grant_type=client_credentials&client_id={bridgeAppId}&scope={tenant2ApiAppId}/.default&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion={(string)context.Variables["assertionToken"]}";
}</set-body>
</send-request>
<!-- Step 3: Extract final token -->
<set-variable name="finalTokenBody" value="@{
return ((IResponse)context.Variables["finalTokenResponse"]).Body.As<string>();
}" />
<set-variable name="finalToken" value="@{
return JObject.Parse((string)context.Variables["finalTokenBody"])["access_token"].ToString();
}" />
<!-- Step 4: Set Authorization header with the final token -->
<set-header name="Authorization" exists-action="override">
<value>@("Bearer " + (string)context.Variables["finalToken"])</value>
</set-header>
</inbound>
<backend>
<forward-request />
</backend>
<outbound>
<set-header name="X-Debug-Backend-URL" exists-action="override">
<value>@(context.Request.Url.ToString())</value>
</set-header>
<set-header name="X-Debug-Token-Exchange" exists-action="override">
<value>Success</value>
</set-header>
</outbound>
<on-error>
<set-header name="X-Error-Message" exists-action="override">
<value>@(context.LastError.Message)</value>
</set-header>
<set-header name="X-Error-Source" exists-action="override">
<value>@(context.LastError.Source)</value>
</set-header>
<set-body>@{
var errorDetails = new JObject(
new JProperty("error", context.LastError.Message),
new JProperty("source", context.LastError.Source),
new JProperty("reason", context.LastError.Reason)
);
if (context.Variables.ContainsKey("assertionToken")) {
var token = (string)context.Variables["assertionToken"];
errorDetails.Add("assertionToken", token.Substring(0, Math.Min(100, token.Length)) + "...");
}
if (context.Variables.ContainsKey("finalTokenResponse")) {
var finalResp = ((IResponse)context.Variables["finalTokenResponse"]).Body.As<string>();
errorDetails.Add("finalTokenBody", finalResp);
}
return errorDetails.ToString();
}</set-body>
</on-error>
</policies>
What this policy is doing
- set-backend-service & rewrite-uri: Configure routing first – where to send the request
- send-request with authentication-managed-identity: The UAMI authenticates and gets an assertion token with
aud: api://AzureADTokenExchange. Note: This is wrapped in a send-request to a dummy URL (management.azure.com) just to trigger the authentication. - send-request: Exchanges the assertion token for a real access token from tenant2, specifying the tenant2 API app as the scope (
{tenant2ApiAppId}/.default) - set-variable: Extracts the access token from the JSON response in two steps (body → token)
- set-header: Adds the final token as the Authorization header
- outbound: Debug headers to help troubleshoot
- on-error: Comprehensive error handling with token snippets for debugging
Key Variables to Replace
YOUR_UAMI_CLIENT_ID: The UAMI Client ID from Step 1YOUR_TENANT2_ID: tenant2 Tenant IDYOUR_BRIDGE_APP_ID: Bridge App ID from Step 3YOUR_TENANT2_API_APPID: tenant2 API App ID from Step 6YOUR_TENANT2_APIM_NAME: Your tenant2 APIM instance name
tenant2 APIM Policy (Inbound)
This policy validates the incoming token with role-based authorisation:
<policies>
<inbound>
<base />
<!-- Validate JWT token with role-based authorization -->
<validate-jwt header-name="Authorization"
failed-validation-httpcode="401"
failed-validation-error-message="Unauthorized. Access token is missing or invalid.">
<openid-config url="https://login.microsoftonline.com/YOUR_TENANT2_ID/.well-known/openid-configuration" />
<audiences>
<audience>YOUR_TENANT2_API_APPID</audience>
</audiences>
<issuers>
<issuer>https://sts.windows.net/YOUR_TENANT2_ID/</issuer>
</issuers>
<required-claims>
<claim name="aud" match="any">
<value>YOUR_TENANT2_API_APPID</value>
</claim>
<claim name="roles" match="any">
<value>access_as_app</value>
</claim>
<claim name="appidacr" match="any">
<value>2</value>
</claim>
</required-claims>
</validate-jwt>
<!-- Optional: Return success response without backend for testing -->
<return-response>
<set-status code="200" reason="OK" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
var jwt = context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt();
var roles = jwt?.Claims.GetValueOrDefault("roles", "[]");
var oid = jwt?.Claims.GetValueOrDefault("oid", "unknown");
var aud = jwt?.Claims.GetValueOrDefault("aud", "unknown");
return new JObject(
new JProperty("message", "Token validated successfully!"),
new JProperty("from", "tenant2 APIM"),
new JProperty("authenticated", true),
new JProperty("authorized", true),
new JProperty("audience", aud),
new JProperty("roles", roles),
new JProperty("callerObjectId", oid)
).ToString();
}</set-body>
</return-response>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>
Variables to replace:
YOUR_TENANT2_ID: tenant2 Tenant IDYOUR_TENANT2_API_APPID: tenant2 API App ID from Step 6
Whats being validated?
- openid-config: OIDC metadata endpoint for token validation (gets public keys)
- audiences: Token must be intended for the tenant2 API app
- issuers: Token must be issued by tenant2
- aud claim: Must match tenant2 API App ID (OAuth 2.0: audience = resource)
- roles claim: Caller must have the
access_as_approle (authorisation) - appidacr claim: Must be
2(ensures federated credential authentication, not password/secret)
If any of these checks fail, the request is rejected with a 401.
Three-Layer Security
- Authentication (Who are you?): Validates JWT signature, issuer, and expiration
- Authorisation (What can you do?): Validates
aud,roles, andappidacrclaims - OAuth 2.0 Best Practice: Token audience matches the protected resource (tenant2 API app), NOT the authentication bridge app
Optional return-response section: This returns a test response without calling a backend API. Remove this in production to forward to your actual backend service.
Testing the Setup
Time to test! Here’s how to verify everything works:
Test from tenant1 APIM
Make a request to your tenant1 APIM endpoint:
curl -X GET "https://$TENANT1_APIM_NAME.azure-api.net/your-api-path/validate
You should get a successful response from tenant2’s backend API!
Expected Response
If everything is configured correctly, you should get a successful response like this:
HTTP/1.1 200 OK
content-length: 148
content-type: application/json
x-debug-backend-url: https://tenant2-apim.azure-api.net/your-api-path/validate
x-debug-token-exchange: Success
{
"message": "Token validated successfully!",
"from": "tenant2 APIM",
"authenticated": true,
"authorized": true,
"audience": "XXXXX-XXXXXXX",
"roles": ["access_as_app"],
"callerObjectId": "XXXXX-XXXXXXX"
}
The response shows:
- audience: The tenant2 API App ID (token is FOR this resource)
- roles: The
access_as_approle granted in Step 7 - callerObjectId: The tenant1 SP Object ID (who made the request)
- Debug headers: Confirm backend routing and token exchange success
Example response below from my testing setup:

Security Considerations
A few important security notes:
- No secrets to rotate: The managed identity uses Azure-managed certificates
- Least privilege: Only grant the specific app role needed (
access_as_app) - Token validation: Always validate
aud,roles, andappidacrclaims - Audit logs: Enable Entra ID sign-in logs to monitor authentication attempts
- Scope to specific APIs: Each tenant2 API app should have its own app roles
Wrapping Up
Awesome! You’ve now set up passwordless cross-tenant authentication between two APIM instances using federated credentials. Here’s what we covered:
1. Architecture: UAMI → Bridge App → tenant2 API App with two service principals
2. Two-step token exchange: Assertion token → Final access token with app roles
3. APIM policies: Automated token acquisition and validation
4. Testing: How to verify the complete flow works
This pattern is particularly useful for:
- Multi-tenant SaaS applications
- Partner integrations
- Internal service-to-service communication across business units
- Any scenario where you want passwordless cross-tenant auth
Try various configurations to see what works best for your architecture – federated credentials are incredibly flexible! As always, if you have any questions or run into issues, feel free to reach out. I’d love to hear how you’re using this pattern in your environment!
Additional reading resources: