The Problem
In an enterprise integration platform built on Azure Logic Apps Standard, you will almost always end up with multiple Logic Apps in the same resource group — each handling a different integration domain. It is also common to share infrastructure: a single Office 365 managed connection, a single on-premises data gateway connection, or a set of SFTP connections that several Logic Apps legitimately need.
This creates two design tensions:
- Security: You do not want Logic App A to be able to use a managed connection intended only for Logic App B.
- Operability: You do not want to provision a separate copy of every connection for every Logic App — that duplicates infrastructure and doubles your maintenance burden.
The answer is API Connection Access Policies combined with Managed Identity. You provision each managed connection once, shared, and then control which Logic App can authenticate to it at the ARM level — not at the network or key level.
Background: How Managed API Connections Work in Logic Apps Standard
Logic Apps Standard uses two kinds of connections:
| Type | Example | Authentication |
|---|---|---|
Managed API connection (Microsoft.Web/connections) |
Office 365, SFTP, on-premises filesystem | OAuth / Managed Identity via ARM |
| Service provider connection (built-in) | Service Bus, Azure Blob, FTP | App settings (connection string or credential) |
This article focuses on managed API connections. These are full ARM resources in your resource group. When a Logic App calls a managed connector at runtime, it presents the Managed Identity token of the Logic App app service and ARM validates whether that identity is authorised to use that specific connection. This is controlled by an access policy — a child resource of Microsoft.Web/connections.
If no access policy exists for the calling identity, the connection call fails at runtime even if the connection itself is healthy.
The Pattern: One Connection, Multiple Logic Apps, Granular Access
Architecture Overview
Resource Group
│
├── office365-conn (Microsoft.Web/connections)
│ └── accessPolicies/
│ └── policy-logicapp-billing ← only billing LA can call this
│
├── sftp-partner-conn (Microsoft.Web/connections)
│ └── accessPolicies/
│ ├── policy-logicapp-inbound ← inbound LA can call this
│ └── policy-logicapp-outbound ← outbound LA can call this
│
├── logicapp-billing (Microsoft.Web/sites, kind: workflowApp)
│ └── System-assigned identity: xxxxxxxx
│
├── logicapp-inbound (Microsoft.Web/sites, kind: workflowApp)
│ └── System-assigned identity: yyyyyyyy
│
└── logicapp-outbound (Microsoft.Web/sites, kind: workflowApp)
└── System-assigned identity: zzzzzzzz
Key points:
-
office365-connis shared infrastructure — provisioned once. -
sftp-partner-connis also shared but accessible by two Logic Apps. - Each Logic App has its own system-assigned Managed Identity.
- Access policies tie a specific identity to a specific connection. The billing Logic App cannot call the SFTP connection because there is no access policy for it on that connection.
Provisioning Connections (Shared Infrastructure)
Connections are provisioned via Bicep and deployed once, idempotently. Because multiple Logic Apps may need the same connection, this step is decoupled from individual Logic App deployments — it runs as a shared infrastructure step.
resource office365Conn 'Microsoft.Web/connections@2016-06-01' = {
name: 'office365-conn'
location: location
properties: {
displayName: 'office365-conn'
api: {
id: subscriptionResourceId('Microsoft.Web/locations/managedApis', location, 'office365')
}
parameterValues: {
// credentials injected from pipeline variable group
}
}
}
All connections in the platform are provisioned in a single Bicep deployment with --mode Incremental — meaning re-running it is safe and does not disrupt existing connections or their existing access policies.
Assigning Access Policies Per Logic App
This is the key step that enforces isolation. Each Logic App deployment pipeline runs the following sequence as part of its deployment.
Step 1 — Fetch the Logic App's Managed Identity
The Logic App must be provisioned first so that Azure has assigned it a system-assigned Managed Identity. Because the identity may take a few seconds to propagate after provisioning, the pipeline polls with retries:
$maxAttempts = 10
$delaySeconds = 15
$laPrincipal = $null
for ($i = 1; $i -le $maxAttempts; $i++) {
$laPrincipal = az logicapp show `
-g $resourceGroup `
--name $logicAppName `
--query identity.principalId `
--output tsv
if (-not [string]::IsNullOrWhiteSpace($laPrincipal)) { break }
Write-Host "Attempt $i/$maxAttempts — identity not yet available, retrying in ${delaySeconds}s..."
Start-Sleep -Seconds $delaySeconds
}
if ([string]::IsNullOrWhiteSpace($laPrincipal)) {
Write-Error "Managed identity did not become available after $($maxAttempts * $delaySeconds)s."
exit 1
}
This principal ID is the object ID of the Logic App's Managed Identity in Azure AD. It is passed forward as a pipeline variable for the next step.
Step 2 — Prepare the Access Policy Body
The access policy document is stored as a template file, with the principal ID and tenant ID injected by the pipeline's token replacement task:
{
"type": "Microsoft.Web/connections/accessPolicy",
"location": "#{var_location}#",
"properties": {
"principal": {
"type": "ActiveDirectory",
"identity": {
"objectId": "#{laPrincipal}#",
"tenantId": "#{var_tenant}#"
}
}
}
}
Tokens (#{...}#) are replaced at pipeline runtime before the file is used in the PUT call.
Step 3 — PUT the Policy on the Relevant Connections
Two approaches exist depending on how connections are named in the resource group.
Approach A — All connections
Assign the policy to every Microsoft.Web/connections in the resource group. This works well when all connections in the resource group belong to a single Logic App's domain:
$connections = az resource list `
-g $resourceGroup `
--resource-type Microsoft.Web/connections | ConvertFrom-Json
$maxAttempts = 10
$delaySeconds = 15
foreach ($conn in $connections) {
$url = "https://management.azure.com/subscriptions/$subscriptionId" +
"/resourceGroups/$resourceGroup" +
"/providers/Microsoft.Web/connections/$($conn.name)" +
"/accessPolicies/policy-$logicAppName" +
"?api-version=2018-07-01-preview"
# Idempotency: skip if the correct policy already exists
$existingPrincipal = az rest --method get --url $url `
--query "properties.principal.identity.objectId" --output tsv 2>&1
if ($existingPrincipal -eq $laPrincipal) {
Write-Host "Policy already correct on $($conn.name), skipping."
continue
}
# ARM returns HTTP 500 if the same principal already has a policy under a different name.
# Delete any stale policy for this principal before creating the new one.
$staleIds = az rest --method get `
--url "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.Web/connections/$($conn.name)/accessPolicies?api-version=2018-07-01-preview" `
--query "value[?properties.principal.identity.objectId=='$laPrincipal'].id" `
--output tsv 2>&1
$deleted = $false
foreach ($policyId in ($staleIds -split "`n" | Where-Object { $_ -ne '' })) {
az rest --method delete `
--url "https://management.azure.com${policyId}?api-version=2018-07-01-preview" 2>&1 | Out-Null
Write-Host "Deleted stale policy on $($conn.name): $policyId"
$deleted = $true
}
if ($deleted) { Start-Sleep -Seconds 2 } # Allow ARM to process deletion
# PUT the new policy with retry
$success = $false
for ($i = 1; $i -le $maxAttempts; $i++) {
$result = az rest --method put --url $url --body @accessPolicyProperties.json 2>&1
if ($LASTEXITCODE -eq 0) { $success = $true; break }
Write-Host "Attempt $i/$maxAttempts for $($conn.name) failed: $result"
if ($i -lt $maxAttempts) { Start-Sleep -Seconds $delaySeconds }
}
if (-not $success) {
Write-Error "Failed to set access policy on $($conn.name) after $maxAttempts attempts."
exit 1
}
}
Approach B — Prefix filtering + explicit shared connections
When the resource group contains connections for multiple Logic Apps — named with a prefix convention such as lawf-io-*, lawf-order-* — you can filter the connection list by prefix. This avoids assigning access policies across unrelated connections.
# Only the connections owned by this Logic App
$connections = az resource list `
-g $resourceGroup `
--resource-type Microsoft.Web/connections `
--query "[?starts_with(name, 'lawf-io-')]" | ConvertFrom-Json
foreach ($conn in $connections) {
# ... same PUT logic as Approach A
}
Some connections may be shared across Logic Apps and not carry any particular prefix (e.g. office365-conn, onpremfs-conn). These are passed in as an explicit parameter — either from the pipeline call or from a variable group — and processed separately:
# sharedConnections: space or comma-separated list from pipeline parameter or variable group
$sharedParam = $sharedConnectionsParam.Trim()
if ([string]::IsNullOrWhiteSpace($sharedParam) -or $sharedParam -eq 'none') {
$sharedParam = $env:VAR_SHARED_CONNECTIONS.Trim()
}
foreach ($connName in ($sharedParam -split '[,\s]+' | Where-Object { $_ -ne '' })) {
$exists = az resource list `
-g $resourceGroup `
--resource-type Microsoft.Web/connections `
--query "[?name=='$connName'].name" -o tsv 2>&1
if (-not $exists) {
Write-Host "Shared connection '$connName' not found in $resourceGroup, skipping."
continue
}
$url = "https://management.azure.com/subscriptions/$subscriptionId" +
"/resourceGroups/$resourceGroup" +
"/providers/Microsoft.Web/connections/$connName" +
"/accessPolicies/policy-$logicAppName" +
"?api-version=2018-07-01-preview"
az rest --method put --url $url --body @accessPolicyProperties.json
}
This approach keeps the default access surface minimal — only named shared connections are granted, not everything in the resource group.
Generating connections.json
Logic Apps Standard requires a connections.json file alongside the workflow definitions. This file maps connection reference names (used in workflow.json) to the actual ARM resource IDs and live runtime URLs of the managed connections.
Rather than maintaining this file manually — which would require hard-coded subscription IDs and environment-specific runtime URLs — the pipeline generates it dynamically from Azure at deploy time using a script.
What the script does
The script queries the resource group for:
Managed API connections (
Microsoft.Web/connections) — reads each connection's ARM properties to get itsapi.id, resourceid, andconnectionRuntimeUrl. TheconnectionRuntimeUrlis the endpoint Logic Apps Standard uses at runtime to route calls through the managed connector; it is only available from ARM after the connection is provisioned.Function app connections (
Microsoft.Web/siteswith HTTP-triggered functions, when-withFunctionsis passed) — reads each function app, lists its HTTP-triggered functions, and fetches the host key used for authentication.
Function Get-ApiConnections {
$apiConnections = @{}
$resources = Get-AzResource -ResourceGroupName $resourceGroup -ResourceType Microsoft.Web/connections
# Optional: filter by name prefix when multiple Logic Apps share the same resource group
if ($connectionNamePrefix) {
$resources = $resources | Where-Object { $_.Name.StartsWith($connectionNamePrefix) }
}
$resources | ForEach-Object {
$name = $_.Name
$connectionResource = Get-AzResource -ResourceId $_.Id
$apiConnections[$name] = @{
"api" = @{ "id" = $connectionResource.Properties.api.id }
"connection" = @{ "id" = $_.Id.ToLower() }
"connectionRuntimeUrl" = $connectionResource.Properties.connectionRuntimeUrl
"authentication" = @{ "type" = "ManagedServiceIdentity" }
}
}
return $apiConnections
}
Function Get-FunctionConnections {
$functionConnections = @{}
$sites = Get-AzResource -ResourceGroupName $resourceGroup -ResourceType Microsoft.Web/sites
foreach ($site in $sites) {
# Enumerate HTTP-triggered functions via ARM (no func CLI required)
$funcList = Invoke-AzRestMethod `
-Path "$($site.Id)/functions?api-version=2022-03-01" -Method GET
if ($funcList.StatusCode -ne 200) { continue }
$functions = ($funcList.Content | ConvertFrom-Json).value |
Where-Object { $_.properties.invoke_url_template }
if (-not $functions) { continue }
# Get the host key for authentication
$keysResp = Invoke-AzRestMethod `
-Path "$($site.Id)/host/default/listkeys?api-version=2022-03-01" -Method POST -Payload '{}'
$keysObj = $keysResp.Content | ConvertFrom-Json
$hostKey = $keysObj.functionKeys.default ?? $keysObj.masterKey
foreach ($func in $functions) {
$funcName = $func.id.Split('/')[-1]
$functionConnections[$funcName] = @{
"function" = @{ "id" = $func.id }
"triggerUrl" = $func.properties.invoke_url_template
"authentication" = @{ "type" = "QueryString"; "name" = "Code"; "value" = $hostKey }
"displayName" = $funcName
}
}
}
return $functionConnections
}
Output structure
The script writes a compressed single-line JSON that the pipeline then uses directly:
{
"managedApiConnections": {
"office365-conn": {
"api": { "id": "/subscriptions/.../managedApis/office365" },
"connection": { "id": "/subscriptions/.../connections/office365-conn" },
"connectionRuntimeUrl": "https://xxxxx.common.logic-westeurope.azure-apihub.net/apim/office365/yyy",
"authentication": { "type": "ManagedServiceIdentity" }
},
"sftp-partner-conn": {
"api": { "id": "/subscriptions/.../managedApis/sftpwithssh" },
"connection": { "id": "/subscriptions/.../connections/sftp-partner-conn" },
"connectionRuntimeUrl": "https://xxxxx.common.logic-westeurope.azure-apihub.net/apim/sftpwithssh/zzz",
"authentication": { "type": "ManagedServiceIdentity" }
}
},
"functionConnections": {
"ParseDocument": {
"function": { "id": "/subscriptions/.../functions/ParseDocument" },
"triggerUrl": "https://my-fa.azurewebsites.net/api/parsedocument",
"authentication": { "type": "QueryString", "name": "Code", "value": "<host-key>" },
"displayName": "ParseDocument"
}
}
}
Calling the script in the pipeline
Simple (all connections in the RG):
.\Generate-Connections.ps1 `
-resourceGroup $resourceGroup `
-outputLocation connections.json `
-withFunctions
With prefix filtering and shared connections (Approach B):
.\Generate-Connections.ps1 `
-resourceGroup $resourceGroup `
-outputLocation connections.json `
-connectionNamePrefix "lawf-io-" `
-withFunctions
# Shared connections not matching the prefix are handled separately
# by passing -sharedConnectionNames to a modified version of the script,
# or by running the script a second time against a second resource group.
Key constraint: The key in
managedApiConnectionsis the ARM resource name of the connection. This must exactly match thereferenceNameused inworkflow.json. If the resource is namedoffice365-connbut the workflow referencesoffice365, the runtime connection lookup will fail silently.
Injecting service provider connections
Managed API connections are generated from Azure. Service provider connections (built-in) are not ARM resources and cannot be queried — they are injected into the generated file by the pipeline via sed before it is deployed:
# Inject serviceProviderConnections block into the generated connections.json
sed -i 's/"functionConnections"/"serviceProviderConnections": {
"spc-servicebus": {
"displayName": "spc-servicebus",
"parameterValues": {
"connectionString": "@appsetting('\''serviceBusConnection'\'')"
},
"serviceProvider": { "id": "\/serviceProviders\/serviceBus" }
}
},"functionConnections"/g' connections.json
The final connections.json therefore contains all three sections — managed API connections (from Azure), service provider connections (injected), and function connections (from Azure) — assembled by the pipeline before deploy.
Shared vs. Logic-App-Specific Connections
The pattern supports both types transparently:
| Connection type | Provisioning | Access policy |
|---|---|---|
| Shared (e.g. Office 365, on-prem gateway) | One Bicep deployment for the whole platform | Each Logic App gets its own named policy |
| Logic-App-specific (e.g. a partner SFTP only one LA uses) | Still provisioned in the same shared Bicep step | Only the owning Logic App gets a policy |
The access policy layer is what enforces the distinction at runtime — there is no need to use separate resource groups or separate ARM deployments.
Service Provider Connections: No Access Policies Needed
Built-in service provider connections (Service Bus, Azure Blob, FTP, etc.) do not create an ARM resource — they are configured entirely inside connections.json using app setting references:
"serviceProviderConnections": {
"spc-servicebus": {
"parameterValues": {
"connectionString": "@appsetting('serviceBusConnection')"
},
"serviceProvider": { "id": "/serviceProviders/serviceBus" }
}
}
The actual connection string is stored as a Logic App app setting, injected by the pipeline at deploy time. No access policy is needed because authentication is handled by the connection string itself — the Logic App owns it directly.
The trade-off: managed API connections give you identity-based, policy-controlled access at the ARM level. Service provider connections are simpler to set up but rely on secrets stored in app settings.
End-to-End Pipeline Flow
1. provisionApps → Bicep deploys Logic App + all shared connections (Incremental)
2. provisionConns → Bicep deploys any missing managed API connections
3. fetchIdentity → Poll for Logic App managed identity principal ID
4. injectTokens → Replace #{laPrincipal}# and #{var_tenant}# in access policy template
5. assignPolicies → PUT access policy for this Logic App on each relevant connection
(idempotent: skip if correct, delete stale, retry on failure)
6. mergeAppSettings → az webapp config appsettings set (connection strings, keys)
7. generateConns → Generate-Connections.ps1 queries ARM → writes connections.json
8. injectSPCs → sed injects serviceProviderConnections into connections.json
9. zipDeploy → ArchiveFiles + AzureFunctionApp@1 deploys workflow zip to Logic App
Summary
| Concern | How it is addressed |
|---|---|
| Shared connection infrastructure | Single Bicep deployment, --mode Incremental, one resource per external system |
| Logic App isolation | ARM access policy per Logic App identity on each connection |
| Precise access scoping | Connection prefix filter + explicit shared connection list (Approach B) |
| Stale policy conflicts | Pipeline detects and removes stale policies before re-assigning, with retries |
| Dynamic runtime URLs |
connections.json generated from ARM at deploy time — never committed |
| Service provider connections | App settings injection + sed into generated connections.json
|
| Function connections | Discovered from ARM, host key fetched and embedded automatically |
The result is a platform where connections are provisioned once, runtime URLs are always current, and each Logic App can only authenticate to the connections it is explicitly authorised to use — enforced at the Azure control plane, not in application code.
Top comments (0)