DEV Community

Daniel Jonathan
Daniel Jonathan

Posted on

Securing Shared API Connections in Azure Logic Apps Standard with Access Policies

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:

  1. Security: You do not want Logic App A to be able to use a managed connection intended only for Logic App B.
  2. 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
Enter fullscreen mode Exit fullscreen mode

Key points:

  • office365-conn is shared infrastructure — provisioned once.
  • sftp-partner-conn is 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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}#"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Managed API connections (Microsoft.Web/connections) — reads each connection's ARM properties to get its api.id, resource id, and connectionRuntimeUrl. The connectionRuntimeUrl is 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.

  2. Function app connections (Microsoft.Web/sites with HTTP-triggered functions, when -withFunctions is 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
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Calling the script in the pipeline

Simple (all connections in the RG):

.\Generate-Connections.ps1 `
    -resourceGroup $resourceGroup `
    -outputLocation connections.json `
    -withFunctions
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Key constraint: The key in managedApiConnections is the ARM resource name of the connection. This must exactly match the referenceName used in workflow.json. If the resource is named office365-conn but the workflow references office365, 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
Enter fullscreen mode Exit fullscreen mode

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" }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)