This sample demonstrates Azure API Management in front of an Azure Function App. The Function App serves a small Inventory API, but answers nothing unless the request carries a shared secret, and the only party holding that secret is the API Management gateway: it keeps it in a secret named value and adds it to every backend call through a policy. Clients never see the backend. They call the gateway with a subscription key, and the gateway authorises the call, enforces a rate limit, answers browser preflights, and forwards what remains to the function.
The sample exercises both halves of API Management on the LocalStack Azure emulator: the control plane (service instance, OpenAPI import, product, subscription, named value, policy) and the gateway (subscription-key authorisation, policy execution, rate limiting, CORS).
The solution is composed of the following Azure resources:
- Azure Resource Group: A logical container scoping all resources in this sample.
- Azure Storage Account: The Function App's runtime storage (
AzureWebJobsStorage). - Azure App Service Plan (Linux, B1): Hosts the Function App.
- Azure Function App (Python v2 model): The Inventory backend, with three HTTP-triggered routes:
GET /api/items,GET /api/items/{id}andGET /api/whoami. Every route refuses a request that does not carry a validX-Backend-Secretheader, so the Function App can only be reached through the gateway. - Azure API Management (Consumption tier):
- The Inventory API, imported from
apim/openapi.jsonand published under/inventory, with the Function App as its backend (serviceUrl). - The backend-secret named value, marked secret, holding the shared secret.
- The API policy: a
corspolicy that answers preflights at the gateway, arate-limitof ten calls a minute per subscription,set-headerpolicies that inject the secret from the named value and tell the backend which subscription is calling (@(context.Subscription.Id)), a pair that strips the subscription key in both forms a client may send it -- the header and thesubscription-keyquery parameter -- and an outbound header marking responses that came through the gateway. - The Inventory Partners product containing the API, and the partner-subscription subscription whose key clients present.
- The Inventory API, imported from
%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 70}}}%%
flowchart LR
client((Client))
subgraph apim["API Management (Consumption)"]
direction TB
keycheck["Subscription key check<br/>Inventory Partners product"]
policy["Inventory API policy<br/>cors · rate-limit · set-header"]
nv["Named value<br/>backend-secret (secret)"]
keycheck --> policy
nv -.->|"substituted into the policy"| policy
end
subgraph functionapp["Function App (Python)"]
routes["GET /api/items<br/>GET /api/items/{id}<br/>GET /api/whoami"]
end
client -->|"1: GET /inventory/items<br/>Ocp-Apim-Subscription-Key"| keycheck
policy -->|"2: + X-Backend-Secret<br/>+ X-Caller-Subscription<br/>- Ocp-Apim-Subscription-Key"| routes
routes -->|"3: 200 + items"| policy
policy -->|"4: + X-Served-By"| client
client -. "direct call without the secret: 401" .-> routes
style apim fill:#ffffff,stroke:#999999,color:#333333
style functionapp fill:#ffffff,stroke:#999999,color:#333333
The life of a request: the client calls GET /inventory/items on the gateway with an Ocp-Apim-Subscription-Key header → the gateway checks the key against the product's subscriptions → the API policy runs → the request is forwarded to the Function App's /api/items with X-Backend-Secret and X-Caller-Subscription added and the subscription key removed → the Function App verifies the secret and answers → the gateway adds X-Served-By and returns the response. A call without a key, with a wrong key, or beyond ten calls a minute never reaches the function.
- Docker
- Azure CLI
- lstk CLI
- jq,
zipandopenssl - Terraform (for the Terraform deployment)
- Bicep (for the Bicep deployment)
- A LocalStack account with a valid
LOCALSTACK_AUTH_TOKEN(see the Auth Token guide)
Start the LocalStack Azure emulator and route the Azure CLI to it:
export LOCALSTACK_AUTH_TOKEN=<your_auth_token>
IMAGE_NAME=localstack/localstack-azure localstack start -d
localstack wait -t 60
lstk az start-interception
az login --service-principal -u any-app -p any-pass --tenant any-tenantbash scripts/deploy.shThe script provisions all resources idempotently: it creates the Function App and deploys it from a zip package, creates the API Management instance, stores the generated shared secret both as the Function App's BACKEND_SECRET setting and as the secret named value, imports the API from the OpenAPI document with the Function App as its backend, applies the policy, and creates the product and the subscription. It ends by printing the gateway URL and the command that reads the subscription key. A re-run reuses the stored secret, so the gateway and the Function App stay in agreement.
cd terraform
bash deploy.shThe Terraform variant provisions the same resources declaratively and then deploys the function from a zip package with the Azure CLI. The API is imported from the same apim/openapi.json, and the policy is read from the same apim/inventory-api-policy.xml.
cd bicep
bash deploy.shThe Bicep variant validates and deploys main.bicep into the resource group (generating the shared secret per run) and then deploys the function from a zip package with the Azure CLI. It shares the OpenAPI document and the policy with the other two variants through loadTextContent.
bash scripts/validate.sh
bash scripts/call-api.shvalidate.sh walks the whole chain and exits non-zero on any failure:
- The Function App refuses a direct call without the shared secret (401): the gateway is the only way in.
- The OpenAPI import produced the three operations, matched case-insensitively. API Management normalises
operationIdinto the operation's name — it replaces characters that are not allowed and truncates at 76 — and Microsoft's import-restrictions page also lists lower-casing, though a deployment to real Azure kept the casing (getItemsstayedgetItems,Get ItemsbecameGet-Items). The check does not depend on either behaviour. - A keyless call is refused with Azure's missing subscription key message, and a wrong key with its invalid subscription key message.
- With the subscription key,
listItemsandgetItemare authorised, matched (including the{id}template parameter) and answered by the function; a 404 from the backend passes through untouched; every response carries the outboundX-Served-Byheader. whoAmIshows what the backend received: the injected secret, the calling subscription inX-Caller-Subscription, and noOcp-Apim-Subscription-Key. The same holds for a key passed as thesubscription-keyquery parameter: it authenticates the call and is stripped from the URL the function sees.- A CORS preflight is answered by the gateway itself, from the
corspolicy (asserted on Azure only; see the LocalStack notes). - A path that matches no operation gets the gateway's own 404.
- The eleventh call within a minute is refused with a 429 and a
Retry-Afterheader.
call-api.sh is the user-level smoke test: it reads the key, lists the items and reads one of them. Run right after validate.sh it may be told to wait: the rate limit is still in force for the rest of the minute, and the script honours the Retry-After the gateway sends.
APIM_ID=$(az apim show --name local-inventory-apim-test --resource-group local-rg --query id --output tsv)
KEY=$(az rest --method post \
--url "$APIM_ID/subscriptions/partner-subscription/listSecrets?api-version=2022-08-01" \
--query primaryKey --output tsv)
GATEWAY=http://local-inventory-apim-test.apim.azure.localhost.localstack.cloud:4566
# Refused by the gateway
curl -s "$GATEWAY/inventory/items"{"statusCode": 401, "message": "Access denied due to missing subscription key. Make sure to include subscription key when making requests to an API."}# Forwarded to the Function App
curl -s -H "Ocp-Apim-Subscription-Key: $KEY" "$GATEWAY/inventory/items/2"{"id": 2, "sku": "APIM-002", "name": "Gateway sticker pack", "quantity": 500}# What the backend received: the caller's subscription, no subscription key
curl -s -H "Ocp-Apim-Subscription-Key: $KEY" "$GATEWAY/inventory/whoami" | jq .headers{
"x-caller-subscription": "partner-subscription",
"accept": "*/*",
"user-agent": "curl/7.81.0",
"host": "local-inventory-functionapp-test.azurewebsites.azure.localhost.localstack.cloud:4566"
}The eleventh call within a minute is refused before it reaches the function:
{"statusCode": 429, "message": "Rate limit is exceeded. Try again in 59 seconds."}The key can also be passed as the subscription-key query parameter.
az group delete --name local-rg --yesDeleting an API Management instance soft-deletes it: the name stays reserved until the instance is purged or the retention period ends. To free the name straight away:
az apim deletedservice purge --service-name local-inventory-apim-test --location westeurope- Gateway address. API Management reports Azure's gateway address,
https://<name>.azure-api.net, ingatewayUrl. The emulator claims that name too, but it only resolves once LocalStack's DNS is in front of the machine, so the scripts call the gateway through its local alias,http://<name>.apim.azure.localhost.localstack.cloud:4566, whenever the Azure CLI is pointed at the emulator (az account show --query environmentNameisLocalStack). On Azure they usegatewayUrl. - Backend over plain HTTP. The emulator serves the Function App under its own hostname (the
defaultHostNameit reports) over HTTP, so the API'sserviceUrlishttp://<function app host>/apithere andhttps://...on Azure. The Azure CLI scripts pick the scheme from the environment. Terraform and Bicep take it as an input that defaults to the emulator'shttp, so a real deployment overrides one value rather than editing the template —terraform apply -var backend_scheme=httpsoraz deployment group create --parameters backendScheme=https. The Bicep variant derives the Function App'shttpsOnlyfrom the same parameter, so the app stops accepting plain HTTP in the same step. - Shared secret rather than a function key. On Azure the usual way to lock a Function App to its gateway is the function's host key, injected the same way (an
x-functions-keyheader from a secret named value). This sample has the function check a secret of its own instead, so the same code, policy and deployment run unchanged on the emulator and on Azure without listing host keys. - CORS is answered by the emulator, not by the policy. LocalStack enforces CORS for every hostname it serves, the API Management gateway included: a browser origin outside its allow-list gets a bodiless 403 before the gateway sees the request, and an allowed origin gets the emulator's own preflight answer and response headers rather than those of the API's
corspolicy. To call the emulated gateway from a browser app, allow its origin withEXTRA_CORS_ALLOWED_ORIGINS=http://localhost:3000(orDISABLE_CORS_CHECKS=1) when starting LocalStack. Thecorspolicy in this sample is what answers preflights on Azure, andvalidate.shasserts it there only. - Consumption tier. It provisions in minutes on Azure and has no per-instance health probe; the emulator reproduces both. The rate limit is enforced per subscription and the counts are exact on the emulator, while Azure documents them as approximate, so
validate.shkeeps calling until it sees the 429 rather than asserting the exact call at which it happens. - Updating API Management entities needs an
If-Matchheader, which is whyscripts/deploy.shonly applies the policy withIf-Match: *when it already exists and skips entities that are already there.