Which of the two shared secrets around the Azure API Management gateway can an Entra ID token or a managed identity replace, and what does each new check actually reject? After Part 2, the API trusts a subscription key on the way in and a function key on the way out, and neither one says who is calling. A subscription key that leaks from a partner's config identifies nobody, and the function key works just as well from a laptop as from the gateway, so anyone holding it skips every policy from Part 2. The short answer: the token doesn't replace the subscription key at all, and the managed identity replaces the function key only at the end of a migration.
Checking identity at the gateway means the function code never parses a token, one policy covers every operation of the API, and a rejected call never reaches the Flex Consumption app. The companion repo adds three Entra ID app registrations (deployed with Graph Bicep), a system-assigned managed identity on API Management, validate-azure-ad-token (the Entra-specific sibling of validate-jwt) in the API policy, authentication-managed-identity toward the function, and App Service Authentication on the function app, with the function key kept as a second layer. The Bicep and the function project build clean, and the README has a runbook to deploy and exercise everything. The status codes in this article are the ones Microsoft Learn documents; where Learn is silent, the article says so.
OAuth 2.0 with Entra ID: who gets a token for what
Partners call the order API from their own backends, with no user signed in. That is the client credentials flow: the partner's app authenticates as itself and asks Entra ID for an app-only token for your API. Three app registrations make that work in the sample:
- orders-api represents the API behind the gateway. Callers request tokens for it, and it defines the permission they need.
- orders-test-client stands in for a partner's service.
- orders-backend is the audience API Management uses toward the function. It belongs to the managed identity section; it's a separate registration so that a partner's token for the gateway is useless against the function.
This is the part of infra/entra.bicep that defines the API and lets the test client in:
extension microsoftGraphV1
param resourceToken string
// v2 access tokens: aud is the client ID, iss is https://login.microsoftonline.com/{tenant}/v2.0.
var tokenVersion = 2
var ordersRoleId = guid('orders-api', resourceToken, 'Orders.ReadWrite')
resource ordersApiApp 'Microsoft.Graph/applications@v1.0' = {
uniqueName: 'orders-api-${resourceToken}'
displayName: 'Orders API (${resourceToken})'
signInAudience: 'AzureADMyOrg'
api: {
requestedAccessTokenVersion: tokenVersion
}
appRoles: [
{
id: ordersRoleId
value: 'Orders.ReadWrite'
displayName: 'Orders.ReadWrite'
description: 'Place and read orders through API Management.'
allowedMemberTypes: ['Application']
isEnabled: true
}
]
}
// Only clients with an app role assignment (testClientRole below) can get a token for this API.
resource ordersApiSp 'Microsoft.Graph/servicePrincipals@v1.0' = {
appId: ordersApiApp.appId
appRoleAssignmentRequired: true
}
resource testClientRole 'Microsoft.Graph/appRoleAssignedTo@v1.0' = {
appRoleId: ordersRoleId
principalId: testClientSp.id
resourceId: ordersApiSp.id
}
The extension microsoftGraphV1 line needs the Graph extension registered in bicepconfig.json, and the identity running the deployment needs permission to create app registrations. Many company tenants turn that off for regular users; in that case the Entra part of the deployment is denied and you need someone with the Application Developer role (or higher) to run it.
Three settings in that file decide what the gateway will see later.
requestedAccessTokenVersion: 2 pins the token format. The version is a property of the API's registration, not of the endpoint the client calls: Learn says "The values of null and 1 result in v1.0 tokens, and the value of 2 results in v2.0 tokens", and a new registration starts at null. So a partner calling the v2 token endpoint still gets a v1 token unless you change this. The two versions differ in exactly the claims a gateway checks: a v2 token's iss ends in /v2.0 and its aud "is always the client ID of the API", while a v1 token comes from https://sts.windows.net/{tenant-id}/ and its aud "can be the client ID or the resource URI used in the request" (claims reference). The calling app's ID sits in azp on v2 and in appid on v1. The portal's manifest editor calls this setting requestedAccessTokenVersion under api; older docs, including API Management's own tutorial, still use the Azure AD Graph name accessTokenAcceptedVersion.
The app role is how an app-only caller gets a permission. Delegated scopes land in the scp claim, which is "only included for user tokens". A client credentials token carries the app roles assigned to the calling app in the roles claim instead, so the role needs allowedMemberTypes: ['Application'].
appRoleAssignmentRequired: true on the service principal is the setting the portal shows as Assignment required? under the enterprise application's properties. Without it, Learn's Easy Auth page is blunt about the default: it "allows any client application in your Microsoft Entra tenant to request an access token". With it, Entra refuses to issue a token to an app that has no role assignment (error AADSTS501051), and testClientRole is the assignment that lets the test client through. Every new partner app needs its own assignment.
The partner's service then asks for a token. Graph Bicep can't create client secrets, so the README creates one for the test client and requests a token with curl:
eval "$(azd env get-values)"
CLIENT_SECRET=$(az ad app credential reset --id "$TEST_CLIENT_APP_ID" --display-name requests-http --years 1 --query password -o tsv)
curl -s "https://login.microsoftonline.com/$AZURE_TENANT_ID/oauth2/v2.0/token" \
-d grant_type=client_credentials -d client_id="$TEST_CLIENT_APP_ID" \
--data-urlencode client_secret="$CLIENT_SECRET" -d scope="$ORDERS_API_APP_ID/.default" | jq -r .access_token
The scope is the API's identifier followed by /.default, and for client credentials it always is: an app-only token can't ask for individual permissions, it gets every app role the client has been assigned. The sample sets no Application ID URI on orders-api, so the identifier is the client ID itself. If you add an api:// URI and partners ask for api://.../.default, a v2 token still carries the client ID in aud.
Decoded, the payload of that token carries these claims (values shortened):
{
"aud": "<orders-api client ID>",
"iss": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"azp": "<orders-test-client client ID>",
"roles": [ "Orders.ReadWrite" ],
"ver": "2.0"
}
The gateway policy below checks the first four, iss through the tenant's metadata; ver tells you which format the other four follow.
API Management also has an OAuth 2.0 authorization server setting, and it looks like the place to attach OAuth to an API. It doesn't protect anything. Learn says it "only enables the developer portal's test console (and the test console in the Azure portal) as a client to acquire a token", and on the same page: "In the configuration so far, API Management doesn't validate the access token. It only passes the token in the authorization header to the backend API." Part 4 covers the developer portal; for the gateway, the protection is a policy.
Validating the Entra ID token at the gateway
This is the start of the API policy's inbound section in infra/policies/api-orders.xml:
<inbound>
<base />
<ip-filter action="forbid">
<address-range from="203.0.113.0" to="203.0.113.255" />
</ip-filter>
<validate-azure-ad-token tenant-id="{{tenant-id}}">
<client-application-ids>
<application-id>{{orders-test-client-app-id}}</application-id>
</client-application-ids>
<audiences>
<audience>{{orders-api-app-id}}</audience>
</audiences>
<required-claims>
<claim name="roles" match="any">
<value>Orders.ReadWrite</value>
</claim>
</required-claims>
</validate-azure-ad-token>
<authentication-managed-identity resource="{{orders-backend-app-id}}" />
<set-backend-service backend-id="orders-func" />
<!-- subscription key removal, x-partner header and the POST rate limit from Part 2 -->
</inbound>
validate-azure-ad-token (titled "Validate Microsoft Entra token" in the docs) is the Entra-specific validator. It works on every tier, including Basic v2, and reads the token from the Authorization header unless you tell it otherwise. Each {{...}} is a named value that infra/apim.bicep fills from the deployment: tenant-id from tenant().tenantId, the three app IDs from the outputs of entra.bicep.
-
tenant-idbinds the policy to one tenant. The policy fetches the tenant's signing keys and checks the issuer itself; there is no metadata URL or issuer list to get wrong. The attribute also acceptsorganizationsorcommon, and Learn's own sample usesorganizations, which accepts tokens from any Entra tenant. For a partner API, use the GUID. -
audiencesis the orders-api client ID, because the registration issues v2 tokens. A token Entra issued for any other API in the tenant fails here, even with a valid signature. -
client-application-idspins the callers. Only the listed apps get through, so a second partner means a second<application-id>(and a second role assignment). The policy page doesn't say which claim this element reads; with v2 tokens the caller's ID is inazp, and pinning the token version keeps that question from coming up. -
required-claimsdemandsOrders.ReadWriteinroles.match="any"passes if at least one listed value is present; with a single value,anyandallbehave the same, butanykeeps working when you add a read-only role later.
Assignment required and client-application-ids look redundant, since both limit which apps get in. They act at different points. Assignment required stops Entra from issuing the token in the first place. The policy's allowlist also rejects an app that got the role some other way, for example a role assigned to the wrong app by mistake. Without either, audiences alone admits every app in the tenant that can get a token for orders-api, and with Assignment required off, that is every app in the tenant.
When a check fails, the gateway answers with failed-validation-httpcode, 401 by default, and the call never reaches the backend. The error message "depends on validation issue"; Learn gives "JWT not present." as one example and documents no other texts, so don't build client logic on the message.
Position matters twice. The policy sits after <base />, so the global policy (the correlation ID) and the product's rate limit still run first. And it sits before authentication-managed-identity, which writes API Management's own token into the Authorization header: swap the two and the validator checks the gateway's token instead of the partner's. The policy lives at API scope, not global, because another API on the same instance has a different audience.
When validate-jwt is the better fit
validate-jwt is the general JWT validator, and Learn's OWASP guidance draws the line: validate-azure-ad-token for Entra tokens, validate-jwt "for validating tokens not issued by Microsoft Entra". Reach for it when the issuer is another identity provider, when you use Entra External ID for customers (which validate-azure-ad-token doesn't support), or when you need knobs the Entra policy lacks, such as clock-skew or explicit signing keys.
This is the sample's check written as validate-jwt. It isn't a committed policy (scripts/verify.sh generates a similar variant for a few test calls); it shows how much the Entra policy does for you:
<validate-jwt header-name="Authorization" require-scheme="Bearer"
failed-validation-httpcode="401">
<openid-config url="https://login.microsoftonline.com/{{tenant-id}}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>{{orders-api-app-id}}</audience>
</audiences>
<required-claims>
<claim name="azp" match="all">
<value>{{orders-test-client-app-id}}</value>
</claim>
<claim name="roles" match="any">
<value>Orders.ReadWrite</value>
</claim>
</required-claims>
</validate-jwt>
The openid-config URL is where the policy gets the signing keys and the expected issuer. API Management caches the keys for an hour, and when a token references a key it doesn't know (Entra rotates them), it refetches at most once every five minutes. The /v2.0 in that URL is the version decision again: the v2 metadata document publishes the v2 issuer, so this policy only accepts v2 tokens. The caller allowlist becomes a claim check you write yourself, and its name is azp on v2 but appid on v1. Watch two defaults: clock-skew is zero seconds, so a gateway clock running slightly ahead rejects a token in its last moments; and require-scheme only works on the Authorization header and is silently ignored when you set a custom header-name.
For a non-Entra issuer the shape stays the same: point openid-config at the provider's metadata document, put the audience it issues into audiences, and check whatever claim it uses for permissions.
Mistakes that let the wrong token in, or keep the right one out
The failures fall into two groups. Too strict, and every partner gets a 401 after go-live. Too loose, and the gateway accepts tokens it should reject, and nobody notices because nothing breaks.
The registration still issues v1 tokens. Nobody set requestedAccessTokenVersion, so it's null, and Entra issues v1 tokens even though the client calls the v2 token endpoint. The policy uses validate-jwt with the v2 metadata URL, which expects the https://login.microsoftonline.com/{tenant-id}/v2.0 issuer. Every token carries https://sts.windows.net/{tenant-id}/. The signature is fine, the issuer isn't, and every call is a 401. The fix is to pick one version and pin it on both sides: requestedAccessTokenVersion: 2 in the registration and the /v2.0 metadata URL in the policy, or v1 on both.
Learn's own example can't pass. The API Management tutorial for Entra ID combines a v2 openid-config URL, the v1 issuer https://sts.windows.net/{tenant id}/, an api:// audience, and a required aud claim equal to the client ID. A token has one aud. A v2 token carries the client ID and fails the api:// audience; a v1 token requested for api://... carries that URI and fails the required claim. Copy it literally and no token satisfies it. Take the policy's structure from the tutorial, and the values from the claims reference.
The audience has the wrong format. A v2 token's aud is always the bare client ID. A v1 token's aud is whatever the client asked for, the client ID or the api:// URI, so two partners following different docs present different audiences. If you can't pin v2 yet, list both values in audiences; the policy tries each until one matches.
No audience check. A policy that checks only signature and issuer accepts any token from your tenant, including tokens issued for a completely different API. The validate-jwt reference marks audiences as not required while its description says "at least one audience must be specified"; set it every time instead of finding out what the gateway does without it.
No caller check. Audience correct, but no roles requirement, no allowlist, and Assignment required left off: every app registration in the tenant can request a token for orders-api and the gateway accepts it. The gap shows up in an audit, not in a 401. tenant-id="organizations" widens the same hole to every Entra tenant.
The safe way to see which group you are in is to send a token that should fail. requests.http in the repo has a call with a subscription key and no token, which validate-azure-ad-token rejects with a 401. Request a second token for the orders-backend app and send it to the gateway as well: it has a valid signature and the right issuer, and the audience check must reject it.
Managed identity from API Management to Azure Functions
The inbound section above already contains the line that takes over from the function key:
<authentication-managed-identity resource="{{orders-backend-app-id}}" />
<set-backend-service backend-id="orders-func" />
For that line to work, API Management needs an identity of its own. infra/apim.bicep turns on the system-assigned one and exports its object ID for the function app:
resource apim 'Microsoft.ApiManagement/service@2024-05-01' = {
name: 'apim-orders-${resourceToken}'
// location, tags, sku, publisher ...
identity: {
type: 'SystemAssigned'
}
}
output principalId string = apim.identity.principalId
Entra creates a service principal for it with the same name as the instance, so it shows up as apim-orders-<token> under Enterprise applications. There is no secret to copy anywhere: the gateway gets its tokens from the platform.
On each call, authentication-managed-identity gets a token for resource and sets it as Authorization: Bearer <token>. API Management caches the token until it expires, so Entra isn't in the path of every request. Learn defines resource as "the application ID of the target web API": here it's the client ID of orders-backend, the third registration from the Entra section. entra.bicep gives orders-backend v2 tokens as well, so the token's aud is that client ID and its iss is the tenant's v2 issuer. Pass api://<client-id> instead and the audience follows; the function app then has to list that URI as an allowed audience. Both sides only have to agree.
Three properties of this policy decide where it can go:
-
It overwrites the partner's token. When the request leaves the gateway, the
Authorizationheader carries API Management's token, which is why the validator has to run first. If the function ever needs the partner's identity, copy the claim into a separate header fromvalidate-azure-ad-token'soutput-token-variable-namebefore this line. The sample sends the subscription name asx-partnerinstead. -
It doesn't care where the token goes. "API Management doesn't validate which backend the token is sent to." The token follows
set-backend-service, so keep the two lines next to each other at API scope. At global scope, Learn warns, the token is "available in the policy execution context for all APIs, including those in workspaces." - Policy authors can mint it. Anyone allowed to write policies (Learn names the API Management Service Contributor role) can put this line into an API of their own and send the gateway's token anywhere, or log it. Once the function trusts the gateway's identity, write access to policies is access to the function. Hand out that role like a production credential.
Easy Auth: the function app accepts one identity
The other half lives in infra/main.bicep: App Service Authentication, usually called Easy Auth, with the Microsoft provider. It runs in front of the Functions host, so a rejected request never starts your code.
resource easyAuth 'Microsoft.Web/sites/config@2024-04-01' = {
parent: functionApp
name: 'authsettingsV2'
properties: {
platform: { enabled: true }
globalValidation: {
requireAuthentication: true
unauthenticatedClientAction: 'Return401'
}
identityProviders: {
azureActiveDirectory: {
enabled: true
registration: {
clientId: entra.outputs.backendAppId
openIdIssuer: '${environment().authentication.loginEndpoint}${tenant().tenantId}/v2.0'
}
validation: {
allowedAudiences: [entra.outputs.backendAppId]
defaultAuthorizationPolicy: {
allowedPrincipals: {
identities: [apim.outputs.principalId]
}
}
}
}
}
}
}
Read it from the bottom up; the last setting does the most work.
allowedPrincipals.identities is an allowlist of object IDs, compared with the token's oid claim, and apim.identity.principalId is that object ID. Without this block, Easy Auth accepts any valid token for the audience, and Learn spells out the default: "it also lets anyone within the tenant access the application." orders-backend doesn't require role assignments, so any app in the tenant can get a token for it. With the block, a token that is correctly signed and addressed but belongs to another identity fails, and "Requests that fail these built-in checks get an HTTP 403 Forbidden response." The sibling setting allowedApplications checks appid or azp and wants the identity's client ID, which Bicep doesn't hand you for a system-assigned identity; the object ID is already a template output. Set both and a token has to pass both. One limit to know before you add more callers: the identities list allows 500 characters in total, about a dozen object IDs.
Easy Auth doesn't check app roles ("App Service authentication doesn't perform this validation"), so giving orders-backend a role wouldn't gate anything without code in the function. The identity list is the gate. The Container Apps version of this hop in Front Door, API Management and Container Apps: What Each Hop Trusts adds that role check in code as a second line.
allowedAudiences is the orders-backend client ID, the same string the gateway passes as resource. A partner's token for orders-api carries a different aud, and Easy Auth should reject it; Learn doesn't document which status code a wrong audience gets.
openIdIssuer is the tenant's v2 issuer, built from environment().authentication.loginEndpoint so the same Bicep works in sovereign clouds. Learn's provider page gives the format as <authentication-endpoint>/<tenant-id>/v2.0 and recommends it over the legacy https://sts.windows.net issuer that the portal's express setup still writes. The authsettingsV2 template reference shows /v2.0 in front of the tenant ID; follow the provider page and the tenant's metadata document, which put it at the end. Whether a v2 issuer setting also accepts v1 tokens, Learn doesn't say, which is one more reason to pin v2 on the registration.
Return401 together with requireAuthentication: true answers every request without a valid token with 401, the option Learn marks "recommended for APIs". The alternatives are a 302 to a sign-in page, which an API client can't do anything with, 403 and 404.
There's no client secret in the registration block. The function never signs anyone in; it only validates bearer tokens, and that takes the issuer's public keys, not a secret.
The sample runs on Flex Consumption. No Learn page says Easy Auth is unsupported there, and the Consumption-to-Flex migration guide tells you to "recreate it in your new app" if the old app used it. That is the documentation this setup rests on until the sample has run end to end.
Why the function key is still there
The functions in OrderFunctions.cs are still AuthorizationLevel.Function, and the orders-func backend in apim.bicep still sends the key on every call:
[Function("list-orders")]
public IActionResult List(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "orders")] HttpRequest req)
resource ordersBackend 'Microsoft.ApiManagement/service/backends@2024-05-01' = {
parent: apim
name: 'orders-func'
properties: {
url: functionBaseUrl
protocol: 'http'
credentials: {
header: {
'x-functions-key': ['{{${functionKeyValue.name}}}']
}
}
}
}
That gives the function two checks in two different components. Easy Auth sees "every incoming HTTP request" before "your application handles it" (Learn). Behind it, the Functions host enforces the key: "Unless the authorization level on an HTTP triggered function is set to anonymous, requests must include an access key" (Learn). Learn's Functions security page lists access keys and App Service Authentication as steps of one layered approach, not as alternatives. It doesn't describe how the two combine on a single request, but they sit in sequence, so a request has to get past both.
Each one covers a failure of the other. A leaked key on its own carries no token, so Easy Auth answers 401 before the host looks at the key. And if someone switches Easy Auth off in the portal, or lets unauthenticated requests through while troubleshooting, the host still refuses calls without the key. Learn documents no status code for a missing key, so the most this article claims is that the host rejects the request.
The price: the key is still a secret in a named value, someone still has to rotate it, and a rotation that forgets the copy in API Management still breaks every call through the gateway. The migration path below ends with the step that removes it.
One request, end to end
A partner placing an order passes six checks before OrderFunctions runs. The numbers in the diagram mark where each one happens:
- The partner's service requests a token for
<orders-api client ID>/.defaultwith client credentials. Entra issues it only because the app has anOrders.ReadWriterole assignment. - The service calls the gateway with its subscription key and
Authorization: Bearer <token>. The gateway resolves the subscription (a missing or unknown key is a 401, as in Part 1), then runs the global policy and the product's rate limit. - The API policy runs
ip-filter, thenvalidate-azure-ad-token: signature and issuer of your tenant, audience orders-api,azpon the allowlist,Orders.ReadWriteinroles. Any failure is a 401, and the backend is never called. -
authentication-managed-identityreplaces the partner's token with the gateway's token for orders-backend. The policy routes toorders-func, deletes the subscription key header, addsx-partnerand applies the POST rate limit. The backend entity addsx-functions-key. - Easy Auth on the function app validates the gateway's token: the tenant's v2 issuer, audience orders-backend,
oidequal to API Management's principal ID. - The Functions host checks
x-functions-keyand runs the function. The code seesx-partner, never a token.
The calls that matter more are the ones that skip the gateway and go straight to the function URL:
-
No token at all, with or without a leaked function key: 401 from Easy Auth, because of
Return401. -
A token for orders-backend from another app in the tenant: right signature, right audience, wrong
oid. The identity allowlist rejects it, and Learn's status for that is 403. - The partner's own gateway token: its audience is orders-api, not orders-backend, so Easy Auth should reject it, with a status Learn doesn't document.
-
API Management's token in the hands of a policy author: they can reference the
orders-function-keynamed value just as easily, so neither layer stops them. The only defense is who holds the policy role.
To see what your deployment actually returns, scripts/verify.sh in the companion repo sends the first three direct calls plus the gateway cases from the JWT section (no token, wrong audience, tampered signature, no subscription key, and an expired token if you saved one), and prints the status code and the start of the body next to the code Learn documents. For a few calls it swaps the API policy for a validate-jwt variant and an ip-filter allow-list, then restores the committed policy, so run it against your own instance only. It needs the azd environment from azd up and a secret for the test client:
eval "$(azd env get-values)"
export CLIENT_SECRET=$(az ad app credential reset --id "$TEST_CLIENT_APP_ID" --display-name verify --years 1 --query password -o tsv)
scripts/verify.sh | tee verify.log
The script hasn't been run against a live deployment yet, so treat the expected codes it prints as Learn's predictions, and the actual codes as the answer.
Subscription keys and managed identity do different jobs
Managed identity removes a key, so it's tempting to read it as the replacement for keys in general. It replaces one of the two, on the hop partners never see. The subscription key sits on the hop partners use, and it answers a different question.
A subscription key says which subscription a call belongs to, not who made it. Learn's deployment security guidance is direct about it: "Do not rely on subscription keys as an authentication mechanism: A subscription key identifies an API Management subscription, not a user or app, and is prone to being shared or leaked." The authentication overview recommends a key "in addition to another method of authentication or authorization", for "tracking individual customers' API usage or granting access to specific API products."
That's the job the sample gives it. The product rate limit from Part 2 counts per subscription, the POST limit's counter key is context.Subscription.Id, and the x-partner header and the response cache key come from the subscription. Take the key away and all of them lose the thing they count by. So a partner sends both: the token says which app is calling and what it may do, the key says whose quota the call uses. You could rebuild the counters on the token's azp claim with rate-limit-by-key, but products, per-subscription analytics and the developer portal in Part 4 are all built around subscriptions.
A key without a token is enough where a leak costs you quota rather than data: a read-only catalog, a status feed, an internal consumer you'd cut off by cancelling its subscription. Once an API writes orders or returns customer data, the key is metering and a kill switch, and the token does the authentication.
Managed identity fits the backend hop whenever the backend can validate an Entra token. Easy Auth does that for Functions and App Service. Azure services with Entra RBAC, such as Storage or Key Vault, take the same policy with their own resource plus a role assignment for the gateway's identity. Learn's overview marks managed identity as "Recommended for scoped access to a protected backend resource". It doesn't fit a backend outside your tenant's reach, such as a third-party SaaS API; that one keeps a credential, ideally in Key Vault or API Management's credential manager.
Moving from the function key to the identity
Step 1 is a query; steps 2 and 3 are one deployment each, and each leaves a working path for the partners:
- Find who calls the function without the gateway. Today any holder of the key gets in, and step 3 locks them out. Application Insights already has the data: a function request with no gateway request under the same operation ID most likely came from somewhere else.
requests
| where timestamp > ago(30d) and cloud_RoleName startswith "func-orders"
| join kind=leftanti (requests
| where timestamp > ago(30d) and cloud_RoleName startswith "apim-orders"
| project operation_Id) on operation_Id
| summarize calls = count(), last = max(timestamp) by name
Every caller that shows up needs a new route before step 3: through the gateway with its own subscription and token, or with its own managed identity added to allowedPrincipals. Cover your slowest caller with the time range; a month-end job doesn't show up in a week of data.
-
Give the gateway its identity and send its token. Turn on the system-assigned identity, register the backend app and add
authentication-managed-identityto the API policy. Nothing on the function checks the token yet, so this deployment changes no behavior; the key still does the work. -
Turn on Easy Auth.
authsettingsV2withReturn401, the backend audience and API Management's principal ID. From this deployment on, the key alone gets nobody in, and it becomes the second layer from the previous section. The companion sample stops here.
The last step: drop the key
Once nothing but the gateway calls the function with the key, and Easy Auth has carried production traffic long enough that you trust its configuration, the key only costs you rotations. The trigger change is one word in each of the three functions in OrderFunctions.cs:
// before
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "orders")] HttpRequest req)
// after
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "orders")] HttpRequest req)
Anonymous sounds alarming, but it only switches off the host's key check. Easy Auth still stands in front with requireAuthentication: true; from now on it's the only check.
In apim.bicep, the secure parameter, the secret named value and the backend credential go away:
@secure()
param functionKey string
resource functionKeyValue 'Microsoft.ApiManagement/service/namedValues@2024-05-01' = {
parent: apim
name: 'orders-function-key'
properties: {
displayName: 'orders-function-key'
secret: true
value: functionKey
}
}
// inside ordersBackend.properties
credentials: {
header: {
'x-functions-key': ['{{${functionKeyValue.name}}}']
}
}
So do the apimKey host key resource in function.bicep and the apimFunctionKey parameter in main.bicep that feeds both. Deploy the code first. The HTTP trigger reference describes anonymous as "No access key is required"; it doesn't say what the host does with a key that arrives anyway. The host should ignore it, but this step hasn't run against the sample, so watch the first gateway calls after the code deployment before you remove the key from the infrastructure. In the other order, the key disappears while the functions still demand it, and every call through the gateway fails.
Deleting a resource from Bicep doesn't delete it from Azure. A resource group deployment runs in incremental mode by default, which "leaves unchanged resources that exist in the resource group but aren't specified in the template". The backend loses its credential because the backend itself is redeployed without it, and a redeployed resource gets exactly the properties in the template. The named value and the host key are no longer in any template, so they survive and have to go by hand (or through a deployment stack, which Learn recommends when removing a resource from the file should delete it):
az apim nv delete -g <rg> --service-name <APIM_NAME> --named-value-id orders-function-key --yes
az functionapp keys delete -g <rg> -n <FUNCTION_APP_NAME> --key-type functionKeys --key-name apim
What you get: no secret between gateway and function to store, rotate or leak, and no outage when someone rotates it. What you give up: the layer that caught a disabled or loosened Easy Auth. From here on, a change to authsettingsV2 is as sensitive as a change to the gateway policy, so keep it in Bicep, review it like code, and alert on changes made outside a deployment.
IP filtering, and what Basic v2 can't do at the network level
The sample's API policy starts with the filter from Part 2:
<ip-filter action="forbid">
<address-range from="203.0.113.0" to="203.0.113.255" />
</ip-filter>
It sits before the token check, so a blocked address is turned away before the gateway validates anything. forbid blocks the listed addresses; allow blocks everything else. A request that doesn't pass is rejected, with a 403 Forbidden according to Microsoft's troubleshooting guide; the policy reference itself states no status code. For a partner API, the useful form is usually an allow list of the partners' egress ranges, and because address accepts expressions and named values, the list can change without editing the policy.
Two caveats carry over from Part 2. The reference doesn't say which address the filter compares, so echo context.Request.IpAddress from a test policy before you trust an allow-list. And behind Azure Front Door or another proxy, the gateway sees the proxy; Learn's pattern for Front Door is a different check, check-header on the X-Azure-FDID header, and What Each Hop Trusts builds that chain with Container Apps behind the gateway. A client address forwarded in a header is only as trustworthy as the hop that wrote it.
All of that protects the gateway. The function app is a separate public endpoint, and the obvious idea, letting it accept traffic only from API Management's address, doesn't work on Basic v2:
The v2 tiers and Consumption run "on a shared infrastructure and without a deterministic IP address" (IP addresses in API Management). An access restriction on the function app could only allow the whole region's Azure address ranges, which admits every Azure customer in that region. The ApiManagement service tag doesn't help either: it covers the management endpoint, not the gateway's outgoing calls. The Functions security page suggests restricting the function app to "the IP address of your APIM instance"; that needs a tier with a stable address, and neither Consumption nor any v2 tier has one.
Network isolation of the backend starts at Standard v2, with VNet integration for reaching private backends and inbound private endpoints; Premium v2 can also put the gateway inside your network. The v2 tier overview answers the obvious follow-up in its FAQ: a Basic v2 or Standard v2 instance can't be deployed entirely inside your virtual network.
So on Basic v2, ip-filter decides who may talk to the gateway, and the managed identity with Easy Auth decides who may talk to the function. The second boundary is an identity. Moving to Standard v2 later adds a network layer on top of it, and the identity check stays.
Conclusion
The two shared secrets from Part 2 now carry much less weight. The subscription key still decides whose quota a call uses, but a leaked one gets nobody past the gateway without a token for orders-api from an app on the allowlist. The function key sits behind Easy Auth until you drop it, and on its own it opens nothing. Access is now decided by identity, checked twice: by the gateway for the partner, and by the function app for the gateway. The checks most likely to be missing are also the quietest, because a token policy without a caller allowlist returns 200 for the right partner, and, with Assignment required off, for every other app in your tenant as well.
Part 4 moves to the partners' side of the gateway: the developer portal and API versioning.
Does your gateway's token check pin the calling apps, or would a token from any app in your tenant get through?


Top comments (0)