Process Start

Hello again! I’ve been down a rabbit hole with work lately, but it’s time to come up for air and share what I’ve been working on (on and off) over the past few months.

As the title implies I’ve been dabbling in some EventGrid lately, but first some context as to why - let me run a scenario by you.

Picture this: your customer has an SFTP server where their partners upload files of various types for ETL processing. Your integration platform of choice would typically poll the server for new files and then pull them off the Server and do whatever it needed to do with them.

It’s a classic start to any integration story, SFTP has been with us for years and will continue to be with us for many more.

More context! About 8 years ago I did a Logic Apps Consumption integration project for a client where we used an SFTP middleware application that could use varying types of cloud storage as the underlying storage system, but it presented an SFTP interface on top of that storage service. It let us do a neat trick: Our client’s customers connected to the server using its SFTP front end (unaware that it was actually a storage account under the hood); however we (our integration back end) connected directly to the underlying blob storage layer (we used this middleware because Azure Storage SFTP wasn’t a thing yet).

This let us take advantage of EventGrid System Topic Events. It had several advantages:

  • It was bleeding fast for one thing - the event firing and our integrations triggering was very low latency.
  • Secondly we were able to make use of advanced filters to selectively trigger depending on properties about the file.

It was a good pattern and scaled very well, so when my current customer presented a similar use case it was something I was keen to implement again but this time with some notable differences.

  • We no longer had need of an SFTP middleware appliance; Azure had since given us SFTP on Azure Storage.
  • Logic Apps Standard was the tool of choice we wanted to utilize due to its VNet/Private Endpoint integration capabilities (stick a pin that, we need to come back to that later).

With eight more years of experience under my belt, I wanted to improve the original design, especially in terms of security. One specific aspect of that is going to be how EventGrid and Logic Apps communicate with one another. In my original implementation many years ago I had utilized Logic App SAS token authentication in the EventGrid Topic Subscriptions, EventGrid stores these securely as secrets within the service but if you really want to tighten the security screws you can do better.

Enter Easy Auth

Logic Apps Standard is built on top of Azure Functions (which in turn are a derivative of the Microsoft App Service platform). This gives the many of the platform benefits of Function Apps, including the ability to configure Entra Authentication (AKA Easy Auth) without modifying your code.

Entra Authentication for EventGrid Topic Subscriptions

EventGrid supports several authentication mechanisms depending on the destination of the topic subscription, because I needed to use a Webhook as the destination endpoint this ruled out Managed Identity/RBAC authentication, and as stated earlier I wanted to avoid Access Key w/ QueryString based authentication. This meant I was using Entra Auth. Perfect, since I was in theory using Entra Auth on the Logic App (via Easy Auth).

Setup!

Entra Artifact Setup

The first thing we need to do is setup our Entra Application Registrations, Service Principals, Roles, and Role Assignments. I used azure bicep with the graph extensions to setup my two application registrations and their respective service principals.

I chose this approach because I liked the idea of having the Entra Auth configuration in source control. Breaking it up this way also helped me better understand the solution setup (personally I have found some of the documentation a little obtuse as to what things are doing and why and compartmentalizing it has helped).

Credit to Mattias Lögdberg for pointing me in the direction of his colleague Jonas Gaverus’s post post detailing their setup steps when accomplishing this with Azure Functions, I’ve used their Graph Bicep module as a basis and made some tweaks. The resulting module is detailed below.


/*****************************************************
  Entra Client/Application Service Principals
  Creates the necessary Application Registrations and Service Principals
  for EventGrid System Topic Webhook Entra Authentication. 
  Intended to call a Logic App Standard Workflow or Azure Function endpoint with Easy Auth enabled.
******************************************************/
extension 'br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.1.9-preview'
@description('A name to represent the identity of the logical destination application, e.g., the Function App, or Logic App we are sending the events to.')
param destinationApplicationName string
@description('The name of application registration, the identity of which the EventGrid Subscription will use to authenticate with the destination application, the one defined by destinationApplicationName')
param eventGridSubscriptionAppName string
@description('Unique Correlation Identifier for PUT/POST requests to Entra as per https://learn.microsoft.com/en-us/community/content/microsoft-graph-bicep-extension')
param uniqueName string
@description('A unique GUID to represent the application registration, used for the app role ID.')
param uniqueIdGuid string = '044f7006-6bf8-4fbc-a318-fd7e21c95563'
param appRoleName string = 'AzureEventGridSecureWebhookSubscriber'
// Define the Entra App Registration for the destination application
resource entraAppConfiguration 'Microsoft.Graph/applications@v1.0' = {
  description: 'Backend for ${destinationApplicationName} instance'
  displayName: destinationApplicationName
  uniqueName: uniqueName
  signInAudience: 'AzureADMyOrg'
  appRoles: [
    {
      allowedMemberTypes: [
        'User'
        'Application'
      ]
      description: 'Azure EventGrid Role'
      displayName: appRoleName
      id: uniqueIdGuid
      isEnabled: true
      value: appRoleName
    }
  ]
}
var identifierUri = 'api://${entraAppConfiguration.appId}'
//Define an App Identifier
resource entraAppIdentifier 'Microsoft.Graph/applications@v1.0' = {
  identifierUris: [
    identifierUri
  ]
  uniqueName: uniqueName
  displayName: destinationApplicationName
}
// Add a Service Principal for the App Registration (Enterprise Application/Managed Application in Local Tenant)
resource entraAppServicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = {
  displayName: destinationApplicationName
  appId: entraAppConfiguration.appId
}
// Define an App Registration for the Identity of the EventGrid Event Forwarding from the Topic to the Destination Application
resource eventGridEventForwarderApplicationReg 'Microsoft.Graph/applications@v1.0' = {
  displayName: eventGridSubscriptionAppName
  description: 'Entra App Registration for EventGrid Event Sender - ${eventGridSubscriptionAppName}'
  uniqueName: '${uniqueName}-EGSub'
}
resource eventGidSubServicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = {
  displayName: eventGridSubscriptionAppName
  appId: eventGridEventForwarderApplicationReg.appId
}
output entraAppId string = entraAppConfiguration.appId
output egSubAppId string = eventGridEventForwarderApplicationReg.appId

Deploying this Bicep module then gives us the two Application Registrations and Service Principals we need to configure entra auth, the ID’s of which are emitted as output variables. Taking these variables we can then use some PowerShell to assign the relevent objects to one another.


# Slightly modified version of script defined here https://learn.microsoft.com/en-us/azure/event-grid/scripts/powershell-webhook-secure-delivery-microsoft-entra-app
# This script assigns a specific App Role defined under one Application Registration to a Service Principal defined under another Application Registration.
# It also assigns the Microsoft.EventGrid Service Principal to the same role.
# If running manually, ensure you use az login to authenticate first,
# If running in a Pipeline, the AzurePowerShell or AzureCLI task will handle authentication for
# you if configured with an appropriate service connection.
param(
    $eventGridAppId = "4962773b-9cdb-44cf-a8bf-237846a00ab7",
    $webhookAppObjectId = "The Object ID of the Event Grid Receivers App Reg (NOT the objectId of the Managed application in local directory)",
    $eventSubscriptionWriterAppId = "The ClientId of your Event Grid Sender App Reg"
)
Function ConvertTo-SecureString 
{
    param (
        [string]$plainText
    )
    $secureString = New-Object System.Security.SecureString
    $plainText.ToCharArray() | ForEach-Object { $secureString.AppendChar($_) }
    return $secureString
}
Connect-MgGraph
# Start execution
try 
{
    # Creates an application role of given name and description
    Function CreateAppRole([string] $Name, [string] $Description)
    {
        $appRole = New-Object Microsoft.Graph.PowerShell.Models.MicrosoftGraphAppRole
        $appRole.AllowedMemberTypes = New-Object System.Collections.Generic.List[string]
        $appRole.AllowedMemberTypes += "Application";
        $appRole.AllowedMemberTypes += "User";
        $appRole.DisplayName = $Name
        $appRole.Id = New-Guid
        $appRole.IsEnabled = $true
        $appRole.Description = $Description
        $appRole.Value = $Name;
        return $appRole
    }
    # Creates Azure EventGrid Microsoft Entra Application if not exists
    # You don't need to modify this id
    # But Azure EventGrid Entra Application Id is different for different clouds
    $eventGridSP = Get-MgServicePrincipal -Filter ("appId eq '" + $eventGridAppId + "'")
    if ($eventGridSP.DisplayName -match "Microsoft.EventGrid")
    {
        Write-Host "The EventGrid Microsoft Entra Application is already defined.`n"
    } 
    else
    {
        Write-Host "Creating the Azure EventGrid Microsoft Entra Application"
        $eventGridSP = New-MgServicePrincipal -AppId $eventGridAppId
    }
    # Creates the Azure app role for the webhook Microsoft Entra application
    $eventGridRoleName = "AzureEventGridSecureWebhookSubscriber" # You don't need to modify this role name
    $app = Get-MgApplication -ApplicationId $webhookAppObjectId
    $appRoles = $app.AppRoles
    Write-Host "Microsoft Entra App roles before addition of the new role..."
    Write-Host $appRoles.DisplayName
    
    if ($appRoles.DisplayName -match $eventGridRoleName)
    {
        Write-Host "The Azure EventGrid role is already defined.`n"
    } 
    else
    {
        Write-Host "Creating the Azure EventGrid role in Microsoft Entra Application: " $webhookAppObjectId
        $newRole = CreateAppRole -Name $eventGridRoleName -Description "Azure EventGrid Role"
        $appRoles += $newRole
        Update-MgApplication -ApplicationId $webhookAppObjectId -AppRoles $appRoles
    }
    Write-Host "Microsoft Entra App roles after addition of the new role..."
    Write-Host $appRoles.DisplayName
    # Creates the user role assignment for the app that will create event subscription
    $servicePrincipal = Get-MgServicePrincipal -Filter ("appId eq '" + $app.AppId + "'")
    $eventSubscriptionWriterSP = Get-MgServicePrincipal -Filter ("appId eq '" + $eventSubscriptionWriterAppId + "'")
    if ($null -eq $eventSubscriptionWriterSP)
    {
        Write-Host "Create new Microsoft Entra Application"
        $eventSubscriptionWriterSP = New-MgServicePrincipal -AppId $eventSubscriptionWriterAppId
    }
    try
    {
        Write-Host "Creating the Microsoft Entra Application role assignment: " $eventSubscriptionWriterAppId
        $eventGridAppRole = $app.AppRoles | Where-Object -Property "DisplayName" -eq -Value $eventGridRoleName
        New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $eventSubscriptionWriterSP.Id -PrincipalId $eventSubscriptionWriterSP.Id -ResourceId $servicePrincipal.Id -AppRoleId $eventGridAppRole.Id 
    }
    catch
    {
        if( $_.Exception.Message -like '*Permission being assigned already exists on the object*')
        {
            Write-Host "The Microsoft Entra Application role is already defined.`n"
        }
        else
        {
            Write-Error $_.Exception.Message
        }
        Break
    }
    # Creates the service app role assignment for EventGrid Microsoft Entra Application
    $eventGridAppRole = $app.AppRoles | Where-Object -Property "DisplayName" -eq -Value $eventGridRoleName
    New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $eventGridSP.Id -PrincipalId $eventGridSP.Id -ResourceId $servicePrincipal.Id -AppRoleId $eventGridAppRole.Id 
    
    # Print output references for backup
    Write-Host ">> Webhook's Microsoft Entra Application Id: $($app.AppId)"
    Write-Host ">> Webhook's Microsoft Entra Application ObjectId: $($app.Id)"
}
catch 
{
    Write-Host ">> Exception:"
    Write-Host $_
    Write-Host ">> StackTrace:"  
    Write-Host $_.ScriptStackTrace
}

Configuring the Event Handler - Logic App Entra Auth Setup

Now that we have the necessary Entra objects created we need to start applying them. We’ll do the Event Handler Application first, as it needs to be configured prior to us creating the EventGrid Topic Subscription for the Subscription Handshake to complete successfully. In our case this is an Azure Logic App, which being a derivative of an Azure App Service, we can configure using SiteConfig/AuthSettingsV2.

The module below assumes we are injecting this config into an existing Microsoft.Web/Sites resource.


/*****************************************************
  App Service Authentication V2 Settings
  
  Configures Entra Auth for a given App Service
******************************************************/
@description('The name of the App Service Resource we are injecting Auth Config into')
param appServiceResourceName string
@description('The ClientId of the application we are using to represent the App Service')
param easyAuthClientId string
@description('An array of Identifiers representing the authorized identities')
param authorizedIdentities array
@description('Allowed audiences for Azure Active Directory authentication.')
param allowedAudiences array
@description('An array of ApplicationIDs for applications allowed to authenticate with the application')
param allowedApplications array
//** Existing Resources **//
resource appServiceInstance 'Microsoft.Web/sites@2024-11-01' existing = {
  name: appServiceResourceName
}
@description('Setup the Easy Auth config settings for the Standard Logic App')
resource applicationAuthSettings 'Microsoft.Web/sites/config@2024-11-01' = {
  name: 'authsettingsV2'
  parent: appServiceInstance // Existing Standard Logic App for Easy Auth to be enabled
  properties: {
    globalValidation: {
      requireAuthentication: true
      unauthenticatedClientAction: 'AllowAnonymous' // Do not change: See note below.
    }
    httpSettings: {
      requireHttps: true
      routes: {
        apiPrefix: '/.auth'
      }
      forwardProxy: {
        convention: 'NoProxy'
      }
    }
    identityProviders: {
      azureActiveDirectory: {
        enabled: true
        registration: {
          openIdIssuer: uri(environment().authentication.loginEndpoint, tenant().tenantId)
          clientId: easyAuthClientId
          clientSecretSettingName: 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET'
        }
        validation: {
          allowedAudiences:union(environment().authentication.audiences, allowedAudiences)  // Azure Management Plane [management.core.windows.net and management.azure.com]
          defaultAuthorizationPolicy: {
            allowedPrincipals: {
              identities: authorizedIdentities
            }
            allowedApplications: allowedApplications
          }
        }
      }
      facebook: {
        enabled: false
      }
      gitHub: {
        enabled: false
      }
      google: {
        enabled: false
      }
      twitter: {
        enabled: false
      }
      legacyMicrosoftAccount: {
        enabled: false
      }
      apple: {
        enabled: false
      }
    }
    platform: {
      enabled: true
      runtimeVersion: '~1'
    }
  }
}
A sample invocation of this module.

// Other Parameters vars etc
// Modules //
// ******* //
module DeployAuthSettings 'submodules/ApplyEntraAuthSettings.bicep' = {
    name: 'DeployAuthSettings'
    params: {
        appServiceResourceName: 'Your App Service Name'
        easyAuthClientId: '' // The value of $($app.AppId) from our script
        authorizedIdentities: [
            '' //The ObjectId of the Microsoft.EventGrid Service Principal in Entra
        ]
        allowedAudiences: [
            '' // The ClientId / AppId of your EventGridSender Application
        ]
        allowedApplications:[
            '4962773b-9cdb-44cf-a8bf-237846a00ab7' // The ApplicationId of the Microsoft.EventGrid Service Principal in Entra (this is the Public Cloud Variant)
        ]
    }
}

Now a quick note on some of the parameters you’d pass to this module. You might be wondering - why the fudge are you configuring your Logic App to use a token Audience other than the App Reg its using. This is somewhat unusual, ordinarily you just be able to do this with one App Reg and the token audience would be the same as the App Registration we’re using as the Easy Auth ClientId. If you did notice that, well spotted, and its a subtle but important nuance of the use case.

In March 2021 Microsoft required that the subscriber client’s service principal (EventGrid Sender in our case) either needs to be an owner, or have a Role assigned to it on the destination application’s service principal (Logic App Receivers in our case). Since we want to adhere to the concept of least privilege we do not want our client Service Principal to own the destination application Service Principal, thus defining a custom role.

This sounds suspiciously like a Claims check under the bonnet, and in the end it amounts to much the same.

So in short, you have to use two separate service principals to accomplish the authentication workflow we are looking to implement - but wait…

There is a third Service Principal which enters the fray! Microsoft.EventGrid. This is a service principal that exists by default in your Entra tenant, and globally has the same ApplicationId (which only varies if you’re using Public cloud, or Government Cloud).

I’ll explain more in the Auth Flow section further down how this works end to end, but it is this identity which makes the request to your application, with a token whose audience is the appId of our EventGridSender application.

A breakdown of the auth settings from the Logic App Site Settings, showing how our parameter values configure the Allowed Token Audience, Allowed Applications and Allowed Identities properties.
This is the end result of our Authentication Settings deployment

Setting Up Your Webhook

You might think we’re done with setting up the Logic App. Good news! You’re wrong!

You’ve got to decide at this point (if you haven’t already planned ahead) whether your EventGrid Topic Subscription is using the CloudEvents Schema, or the EventGrid Schema. This decision (later reflected in the creation of the EventGrid Topic Subscription) dictates the shape of the event payloads reaching your app, and the required logic of handling the validation request that is done by Azure on creating the topic to prevent abuse of the delivery endpoint.

Some helpful background reading.

There are pros and cons to either schema:

  • EventGrid Events Schema
    • For some Azure services, the validation handshake is handled for you under the hood, abstracting the complexity of this step away from you. Logic Apps Consumption (when using the EventGrid Connector), Azure Functions (with EventGrid Trigger Bindings) and Azure Automation with Webhooks will do this for you.
    • Since none of these are relevant for our scenario with Logic Apps Standard this isn’t a Pro in our favour - but it is worth noting the newer Built-In connector may perform the handshake for you (I discounted using it in part as I’d had other issues with it).
  • CloudEvents Schema.
    • Is an open Schema and affords the greatest compatibility going forward.
    • You have to build the handshake logic yourself.

I decided I wanted to use the CloudEvents Schema, mostly as I’d never used it before and wanted to play. I also wanted fine grained control over the handling of the event so I went back to basics and chose to use the HTTP Trigger and HTTP Response Action.

Our workflow must accommodate at least 2 processing routes.

  • The HTTP OPTIONS request that the EventGrid Subscription sends when performing the validation handshake on creating the Subscription. Failure to respond correctly will cause the Subscription to fail to be created.
  • Your normal application processing route on receiving an event via an HTTP POST from the Subscription.

Some quick hacking about gave me the following worklow.

A quick high level view of our event receiver workflow
A quick high level view of our event receiver workflow

Here is the raw workflow.json


{
    "definition": {
        "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
        "actions": {
            "Condition_-_Is_Validation_Request": {
                "type": "If",
                "expression": {
                    "and": [
                        {
                            "not": {
                                "equals": [
                                    "@triggerOutputs()?['headers']?['WebHook-Request-Callback']",
                                    "@null"
                                ]
                            }
                        }
                    ]
                },
                "actions": {
                    "Condition_-_Is_Rate_Limiting_Agreement": {
                        "type": "If",
                        "expression": {
                            "and": [
                                {
                                    "equals": [
                                        "@triggerOutputs()?['headers']?['WebHook-Request-Rate']",
                                        "@null"
                                    ]
                                }
                            ]
                        },
                        "actions": {
                            "Response_-_Allowed-Origin_-_Only": {
                                "type": "Response",
                                "kind": "Http",
                                "inputs": {
                                    "statusCode": 200,
                                    "headers": {
                                        "WebHook-Allowed-Origin": "@{triggerOutputs()?['headers']?['WebHook-Request-Origin']}"
                                    }
                                }
                            }
                        },
                        "else": {
                            "actions": {
                                "Response-Allowed_-_Origin_-_Allowed_-_Rate": {
                                    "type": "Response",
                                    "kind": "Http",
                                    "inputs": {
                                        "statusCode": 200,
                                        "headers": {
                                            "WebHook-Allowed-Origin": "@{triggerOutputs()?['headers']?['WebHook-Request-Origin']}",
                                            "WebHook-Allowed-Rate": "@{triggerOutputs()?['headers']?['WebHook-Request-Rate']}"
                                        }
                                    }
                                }
                            }
                        }
                    }
                },
                "else": {
                    "actions": {
                        "Normal-Response": {
                            "type": "Response",
                            "kind": "Http",
                            "inputs": {
                                "statusCode": 200
                            }
                        }
                    }
                },
                "runAfter": {}
            }
        },
        "contentVersion": "1.0.0.0",
        "outputs": {},
        "triggers": {
            "When_a_HTTP_request_is_received": {
                "type": "Request",
                "kind": "Http",
                "inputs": {
                    "schema": {
                        "type": "object",
                        "properties": {
                            "specversion": {
                                "type": "string"
                            },
                            "type": {
                                "type": "string"
                            },
                            "source": {
                                "type": "string"
                            },
                            "id": {
                                "type": "string"
                            },
                            "time": {
                                "type": "string"
                            },
                            "subject": {
                                "type": "string"
                            },
                            "data": {
                                "type": "object",
                                "properties": {
                                    "api": {
                                        "type": "string"
                                    },
                                    "clientRequestId": {
                                        "type": "string"
                                    },
                                    "requestId": {
                                        "type": "string"
                                    },
                                    "eTag": {
                                        "type": "string"
                                    },
                                    "contentType": {
                                        "type": "string"
                                    },
                                    "contentLength": {
                                        "type": "integer"
                                    },
                                    "blobType": {
                                        "type": "string"
                                    },
                                    "url": {
                                        "type": "string"
                                    },
                                    "sequencer": {
                                        "type": "string"
                                    },
                                    "storageDiagnostics": {
                                        "type": "object",
                                        "properties": {
                                            "batchId": {
                                                "type": "string"
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    },
    "kind": "Stateful"
}

I use some Conditional statements to assert if the incoming request is a Validation Request (and if so, it a normal one or a Rate Limiting one) or a Event delivery request - based on properties within the Request Body, and returning the appropriate response in each scenario.

EventGrid Topic Subscription Setup

With our Logic App Auth Settings and Application Logic setup correctly, we can now move onto setting up the EventGrid Topic Subscription.

This can also be done via Bicep, I’ve included a sample module below.


/*****************************************************
  EventGrid System Topic Subscription - Webhook
  Creates a system topic subscription, configured to route
  events to a configured webhook. Intended as a Logic App Standard
  Workflow or Azure Function endpoint with Easy Auth enabled.
******************************************************/
targetScope = 'resourceGroup'
//** Parameters **//
//*****************//
  @description('The name of the parent topic to which the subscription will be added.')
  param systemTopicName string
  @description('An array list of event types to subscribe to.')
  param eventTypes array
  @description('An advanced filter that checks if the subject of the event begins with a specific string.')
  param basicFilterSubjectBeginsWith string = ''
  @description('An advanced filter that checks if the subject of the event ends with a specific string.')
  param basicFilterSubjectEndsWith string = ''
  @secure()
  @description('The URL of the endpoint to which events will be sent.')
  param endpointUrl string
  @description('The maximum number of events to include in a single batch sent to the endpoint.')
  @minValue(1)
  param maxMessageBatchSize int = 1
  @description('The maximum preferred size of a batch in kilobytes.')
  @minValue(64)
  param maxPreferredBatchSizeKb int = 64
  @description('The maximum number of delivery attempts for each event.')
  param messagedeliveryRetryLimit int = 30 // Maximum number of delivery attempts for each event
  @description('The time to live for each event in minutes.')
  param messageTimeToLiveInMinutes int = 1440 // Time to live for each event in minutes
  @description('The Azure Active Directory Application ID or URI for authentication.')
  param azureActiveDirectoryApplicationIdOrUri string = '' // Required AAD Application ID or URI for authentication
  param TopicSubscriptionName string = '' // Required name for the topic subscription
//** Existing Resources **//
  resource existingSystemTopic 'Microsoft.EventGrid/systemTopics@2025-02-15' existing = {
    name: systemTopicName
  }
//** Resources **//
  resource eventGridTopicSubscription 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2025-04-01-preview' = {
    name: TopicSubscriptionName
    parent: existingSystemTopic
    properties: {
      destination: {
        properties: {
          endpointUrl: endpointUrl
          maxEventsPerBatch: maxMessageBatchSize
          preferredBatchSizeInKilobytes: maxPreferredBatchSizeKb
          minimumTlsVersionAllowed: '1.2'
          azureActiveDirectoryApplicationIdOrUri: azureActiveDirectoryApplicationIdOrUri
#disable-next-line use-resource-id-functions
          azureActiveDirectoryTenantId: tenant().tenantId
        }
        endpointType: 'WebHook'
      }
      filter: {
        subjectBeginsWith: basicFilterSubjectBeginsWith
        subjectEndsWith: basicFilterSubjectEndsWith
        includedEventTypes: eventTypes
        enableAdvancedFilteringOnArrays: true
        isSubjectCaseSensitive: false
      }
      labels: []
      eventDeliverySchema: 'CloudEventSchemaV1_0'
      retryPolicy: {
        maxDeliveryAttempts: messagedeliveryRetryLimit
        eventTimeToLiveInMinutes: messageTimeToLiveInMinutes
      }
    }
  }
//** Outputs **//
output topicSubscriptionName string = eventGridTopicSubscription.name
output topicSubscriptionResourceId string = eventGridTopicSubscription.id

Here is a sample invocation of the module.

//** Parameters **//
    param webhookUrl string
    param eventGridSystemTopicResourceName string
    param eventGridSenderApplicationClientId string
//** Variables **//
    var subscriptionName = 'securedeliverydemosub'
//** Modules **//
    module eventGridSystemTopicSubscriptions '../submodules/EventGridSystemTopicSubscriptionWebhook.bicep' = {
        name: subscriptionName
        params: {
            endpointUrl: webhookUrl
            eventTypes: [
                'Microsoft.Storage.BlobCreated'
                'Microsoft.Storage.BlobDeleted'
            ]
            systemTopicName: eventGridSystemTopicResourceName
            azureActiveDirectoryApplicationIdOrUri: eventGridSenderApplicationClientId
        }
    }

Common Gotchas

Correctly formatting your Logic App Url

Now don’t be hasty configuring the webhook endpoint and thinking you can just omit all of the query string parameters on your Logic App when cutting off the sig, you will get a number of Bad Request’s from the EventGrid Subscription as it tries to validate the endpoint. Here’s an example of a Logic App HTTP Trigger URL:

https://secureeventdeliverysample.azurewebsites.net:443/api/helloworld/triggers/When_a_HTTP_request_is_received/invoke?api-version=2022-05-01&sp=%2Ftriggers%2FWhen_a_HTTP_request_is_received%2Frun&sv=1.0&sig=ATOTALLYREALSIG*%#123

And here’s an example of a correctly formatted one for the purposes of Entra Auth delivery:

https://secureeventdeliverysample.azurewebsites.net:443/api/helloworld/triggers/When_a_HTTP_request_is_received/invoke?api-version=2022-05-01

A confession from the author, after putting this down for 3 months and picking it back up again I kept hammering a url like

https://secureeventdeliverysample.azurewebsites.net:443/api/helloworld/triggers/When_a_HTTP_request_is_received/invoke

Into my webhook endpoint bicep and wondering what I’d broke it took rewatching Kent Weare’s video to remind me of this as I had failed to write this rather important step down before I downed tools!

Understanding Validation Errors

Azure will give you some pretty helpful errors if you’ve gotten this setup wrong when creating the EventGrid Subscription, at first they might not look helpful but if you know what to look for they can tell you how far you’ve gotten.

Here are 3 samples:

Sample #1 - Logic App Workflow Error

{
    "status": "Failed",
    "error": {
        "code": "ResourceOperationFailure",
        "message": "The resource operation completed with terminal provisioning state 'Failed'.",
        "details": [
            {
                "code": "URL validation",
                "message": "Webhook validation handshake failed for https://riccardostotallyreallogicapp.azurewebsites.net/api/helloworld/triggers/When_a_HTTP_request_is_received/invok. Http OPTIONS request retuned 2XX response but without required response header 'WebHook-Allowed-Origin'. When a validation request is accepted without required response header, Http GET is expected on the validation URL that is included in the 'WebHook-Request-Callback' request header(within 10 minutes). For troubleshooting, visit https://aka.ms/esvalidationcloudevent. Activity id:117df0c4-a057-453a-9b12-1ed1cf388580, timestamp: 10/28/2025 10:29:00 PM (UTC)."
            }
        ]
    }
}

You get this one if your workflow is misconfigured and isn’t completing the validation handshake correctly. Check the processing route in your workflow and the response you return.

Sample #2 - Fail to Authenticate

{
    "status": "Failed",
    "error": {
        "code": "ResourceOperationFailure",
        "message": "The resource operation completed with terminal provisioning state 'Failed'.",
        "details": [
            {
                "code": "URL validation",
                "message": "Webhook validation handshake failed for https://riccardostotallyreallogicapp.azurewebsites.net/api/helloworld/triggers/When_a_HTTP_request_is_received/invoke. Http OPTIONS request failed with response code Forbidden. For troubleshooting, visit https://aka.ms/esvalidationcloudevent. Activity id:cfc0f5a9-b254-4eb2-80d6-15843f1f5aba, timestamp: 10/28/2025 9:44:46 PM (UTC)."
            }
        ]
    }
}

You’ll get an explicit Forbidden if you’ve got the Entra Auth misconfigured, usually in the Easy Auth settings on the App Service. Use something like postbin and jwt.ms to check the token EventGrid is sending to the Logic App and confirm you’ve got the correct values in your Entra Auth Config, recheck your Service Principals configuration as well.

Sample #3 - Platform Error

{
    "status": "Failed",
    "error": {
        "code": "ResourceOperationFailure",
        "message": "The resource operation completed with terminal provisioning state 'Failed'.",
        "details": [
            {
                "code": "URL validation",
                "message": "Webhook validation handshake failed for https://lriccardostotallyreallogicapp.azurewebsites.net/api/helloworld/triggers/When_a_HTTP_request_is_received/invoke. Http OPTIONS request failed with response code BadRequest. For troubleshooting, visit https://aka.ms/esvalidationcloudevent. Activity id:6b144956-af95-4551-81e0-d2027f895a36, timestamp: 10/27/2025 2:41:45 PM (UTC)."
            }
        ]
    }
}

If you see this one, BadRequest - good news you authenticated but there’s something wrong with the request and you’re failing to invoke the endpoint, I saw this one when I missed off the QueryString parameters off my Webhook Url, I didn’t see any traces in the Logic App Run history because my request hadn’t made it that far.

Authentication Flow

Because no where I can find actually explains the auth flow clearly, I’ve tried here to articulate it the best as I understand it. I hope this helps with your understanding of this…convoluted process (zoom in or download - it’s an SVG).

A quick high level view of our event receiver workflow

Networking Considerations

Our Logic App is Private Endpoint integrated, but Azure EventGrid cannnot deliver messages over a Private Network when using an HTTP Webhook, traffic comes from the Public Azure network address range, so we have to re-enable Public Network Access for our Logic App with specific provisions.

  • Public Network Access is only enabled from specific virtual networks and IP Addresses
  • We add the following rule:
    The access rule which allows EventGrid traffic ingress into our Logic App
    The access rule which allows EventGrid traffic ingress into our Logic App
    Of Key import here is the Service Tag for the traffic, AzureEventGrid. This means the Logic App will accept traffic from outside the VNet providing it comes from Azure EventGrid traffic range.

Conclusion - Why Go Through All This Effort???

The networking considerations bring us full circle to the core question: Why go through all this effort to create a secure solution? It’s a lot of work to set up! The reason we’ve gone through so much effort to setup Entra Auth and secure delivery is what I like to consider the 3 A’s when designing a solution:

  • Access: How is our application exposed, less exposure is better.
  • Authentication: How do we prove that clients are who they say they are.
  • Authorization: How do we check whether authenticated clients are allowed to do what they’re trying to do.

Azure Virtual Network integration is an Access Control mechanism, because it restricts traffic reaching our application only to traffic within the vNet but - because our Access Controls have weakened by adding the Service Tag, we need to compensate in other areas and this is why our Authentication and Authorization are so important. Because we are exposing the Logic App to traffic outside of the Virtual Network I wouldn’t trust SAS based authentication alone to protect it. With only a SAS key it is impossible for me to assert who you are, only that you have somehow acquired a key that lets you call my app.

Other Odds and Ends

Since I couldn’t think of opportune places to put them as I was writing the post, here are some other useful snippets.

Setting up ServiceTag Via Bicep


resource LogicApp 'Microsoft.Web/sites@2024-04-01' = {
      name: LogicAppName
      location: coreParameters.location
      kind: 'functionapp,workflowapp'
      identity: {
        type: 'SystemAssigned, UserAssigned'
        // Some connectors don't support User Assigned Managed Identities, so we need to ensure the System Assigned Identity is also enabled.
        userAssignedIdentities: {
          '${uaManagedIdentity.id}': {}
        }
      }
      tags: {}
      properties: {
        siteConfig: allowEventGridAccess == true ? {
          ipSecurityRestrictions:[
            {
              ipAddress: 'AzureEventGrid'
              action: 'Allow'
              tag: 'ServiceTag'
              priority: 1
              name: 'eventgridin'
              description: 'Allow EventGrid Traffic'
            }
            {
                ipAddress: 'Any'
                action: 'Deny'
                priority: 2147483647
                name: 'Deny all'
                description: 'Deny all access'
            }
          ]
          publicNetworkAccess: 'Enabled'
        }...

Disabling Sig/Sas Based Auth

We didn’t actually discuss turning off signature based authentication, you have to do this yourself. There are 2 ways of accomplishing this:

  • Either work it into your workflow, as demonstrated aptly by Mattias Lögdberg. You have to ensure that the HTTP Trigger configuration propagates the Headers into the workflow for inspection. Somewhat vexingly this doesn’t appear on the designer so you’ll have to add it using the code view. You will then have to inspect the Headers and terminate your workflow using the terminate or appropriate Http response if the Bearer token isn’t present in the request. - When combined with the Entra Auth done by the Azure App Service this forces your application to mandate the presence of the Authorization header. - This doesn’t disable SAS key auth explicitly but rather consequently as part of mandating the Authorization setting is present.
  • The other approach, and this is a cryptic one - comes from a comment made by MS Engineer Arjun Chiddarwar from the Logic Apps team, that we can disable SIG based auth entirely by setting a property (its a sibling property of siteConfig) on the Logic App Resource.
    resource functionApp 'Microsoft.Web/sites@2024-04-01' = {
        name: functionAppName
        location: location
        kind: 'functionapp,workflowapp'
        properties: {
                    ...,
            logicAppsAccessControlConfiguration: {
                triggers: {
                    sasAuthenticationPolicy: {
                        state: 'Disabled'
                    }
                }
            }
        }
    }
    
    The great part of this approach, any attempt to use a Sas key to authenticate with the workflow just fails, it doesn’t even reach the workflow. The slightly dubious part of the approach is that it’s still entirely undocumented. The bicep sample above would work for deploying the setting but Bicep may well warn you the setting is not defined in the resource provider (though the derived ARM Template will still deploy and work as expected), nor is it documented on the MSDOCS. Use at your own discretion.