A customer project recently gave me the opportunity to replace an Azure Automation account with an Azure Function in a Lifecycle Workflow setup – and I also wanted to challenge myself and gain more hands-on experience with Azure Functions and Azure DevOps pipelines. So this post is about how I did that.
In my earlier post about how to run PowerShell scripts in Lifecycle Workflows I covered the Automation account approach using a Logic App. That still works and I have been running it in production. The overall design here is the same – a custom task extension triggers a Logic App, and the Logic App runs the PowerShell script. The difference is that instead of calling an Automation account runbook, the Logic App now calls an Azure Function directly via HTTP, which returns a response back so the Logic App can send the callback to Entra ID Governance.
Prerequisites
You need the following resources in Azure already setup – I will not cover setting those up in this blog post.
- An Azure Function App running PowerShell 7.6 – Elastic Premium plan (EP1 or higher) recommended*
- A System-Assigned Managed Identity enabled on the Function App
- The managed identity granted the necessary Microsoft Graph API permissions and Exchange Online permissions (same as you would for an Automation account) – Setup: https://github.com/ChrFrohn/Entra-Lifecycle-Workflows/blob/main/Exchange/Setup-ExchangePermissionsForAzureAutomationAccount.ps1
- A custom task extension in Microsoft Entra ID Governance already created
- Azure DevOps
* I use Elastic Premium to avoid cold starts – on Consumption the first call after idle has to load the ExchangeOnlineManagement module, which can be slow. Consumption works fine for low volume and is essentially free, just expect a slower first run.
If you have not created a custom task extension before, you can follow my blog post about how to run PowerShell scripts in Lifecycle Workflows – the process for creating the custom task extension itself is the same, regardless of whether you use an Automation account or an Azure Function in the Logic App.
The Function App project structure
In Azure Functions, each PowerShell script is a separate function inside one Function App. A function is a folder containing a function.json file and a run.ps1 file. The folder name becomes the function name in the URL. So if you have a function called Exchange-UpdateSharedMailboxes, the folder structure will look like below. I’m going to use the Shared mailbox script as the use case in this blog post

The three root-level files configure the Function App itself and are shared across all functions in the app. host.json contains app-wide settings – I set the function timeout to 30 minutes. One thing worth knowing: this setting controls how long the function can execute, not how long the Logic App waits for a response. Because the Logic App calls the function synchronously over HTTP, the real limit is the ~230 second Azure Load Balancer timeout – after that an HTTP-triggered function returns a 502, even though it keeps running. So keep the script under ~230 seconds. That is never an issue for the shared mailbox script here, but worth knowing if you adapt this for something longer-running.
host.json also enables managed dependencies, which tells the runtime to read requirements.psd1 and install the declared PowerShell modules on the first cold start after deployment – the modules are then cached and reused on subsequent cold starts. For Exchange Online scripts you need at least ExchangeOnlineManagement 3.* in that file.
The function.json defines the function as an HTTP POST trigger with function-level authentication. This means a function key is required in the URL to call it – but don’t worry about that, the Logic App handles this automatically when you use the Azure Functions connector (more on that later).
If you want to add more PowerShell scripts to the same Function App, you just create another folder with a function.json and a run.ps1 file. Each folder becomes its own function.
Adapting the PowerShell script
If you already have a PowerShell script running in an Automation account, you only need to make three changes to get it working in an Azure Function. All your business logic, managed identity authentication, Exchange Online calls, and Graph API calls stay exactly the same – the managed identity tokens work identically in Azure Functions.
The first change is how the script receives input. In an Automation account you have a param block at the top of your script like this:
In Azure Functions, the script receives an HTTP request instead – so you need to read the input from the request body. Replace the param block with this:
The using namespace System.Net line at the top lets you use [HttpStatusCode]::OK and [HttpStatusCode]::BadRequest without writing out the full namespace path – if you leave it out, PowerShell won’t recognise those values. The $Request object is provided by the Azure Functions runtime and contains the HTTP request body that the Logic App sends. The script also checks for the input value – if it’s missing, the function returns an HTTP 400 error response so the Logic App knows something went wrong.
The second change is how you handle errors. In an Automation account, when something goes wrong you typically write an error and exit like this:
In Azure Functions, you need to return an HTTP error response instead. This is how the Logic App knows the execution failed. So replace every Exit 1 in your script with something like this:
You need to do this for every Exit 1 in your script.
The third and last change is to add an HTTP success response at the end of your script. In an Automation account, the script just ends after a Write-Output. In Azure Functions, you need to return HTTP 200 so the Logic App knows it succeeded:
That’s it for the script changes. Everything else – Connect-ExchangeOnline -ManagedIdentity, Graph API calls, your business logic stays the same.
The full script for the Shared mailbox script “converted” to Azure Function that i use can be found here:
https://github.com/ChrFrohn/Entra-Lifecycle-Workflows/tree/main/Exchange/Manage%20Shared%20mailboxes
Deploy with Azure DevOps
With Azure DevOps you can set up a CI/CD pipeline that automatically deploys the Function App whenever you push changes to the main branch. You start by creating a new Azure Resource Manager service connection in Azure DevOps – navigate to Project settings and select Service connections, then select Create service connection and in the fly out menu select Azure Resource Manager and then select the resource group where your Azure function is created and then provide it with a name.

Then create an Azure pipeline and then paste the below in to the YAML: How to (You need to select Azure Repos Git, then your repo, then starter pipeline and then paste the below YAML code in to the editor and press save)
Replace <Your-Service-Connection-Name> with the name of your Azure Resource Manager service connection and the <Your-Azure-Functions-Name> and <Repo> paths with your own
(On the first run you might need to grant permissions to Azure function for the Pipeline)

The pipeline has two stages – Build and Deploy. The Build stage zips the FunctionApp/ folder and publishes it as an artifact. The Deploy stage picks up the artifact and deploys it to the Function App using the AzureFunctionApp@2 task.
deploymentMethod: runFromPackage means the Function App runs directly from the zip file rather than extracting it first – this gives faster startup and a read-only filesystem, so don’t expect to edit files through the portal’s App Service Editor when using this method. environment: $(functionAppName) creates a deployment environment entry in Azure DevOps under Pipelines in the Environments section

Configuring the Logic App with the Azure Functions connector
Now for the Logic App itself. With an Automation account, the Logic App needs two actions to run a script and wait for it to finish. With Azure Functions, you only need a single action – the function runs and returns the result directly.
Start by navigating to the Logic App that was created with the custom task extension – you can find it here: Logic apps – Microsoft Azure. Once you have located it, select it and then select Logic app designer.
If you already have Automation account actions in the Logic App, delete them. Then press the + between the trigger and the callback action, and select Add an action.
In the search box that opens up, search for Azure Functions and then select Azure Functions in the search results.
You will then be presented with a list of your Function Apps – select the one you created for the Lifecycle Workflow scripts. After that, select the function you want the Logic App to call.

Now you need to provide the request body. This is where you pass the user’s Object ID from the custom task extension trigger to the Azure Function. In the Request Body field, enter the following:

This expression extracts the user’s Object ID from the custom task extension trigger body – the same value you would have passed as a runbook parameter in the Automation account approach. The Azure Functions connector takes care of authentication automatically using the function key, so you don’t need to copy/paste any URLs with ?code= parameters – one advantage over using a plain HTTP action.
Important callback setting: Run after
To make sure Entra ID Governance Lifecycle Workflows always receives a final task result, configure the callback HTTP action Run after settings to include all outcomes from the Azure Function action: Is successful, Has failed, Is skipped, and Has timed out. If only Is successful is selected, the callback can be skipped when the function fails, and the Lifecycle Workflow task might wait until timeout instead of receiving an explicit failed status.

Important callback setting: operationStatus mapping
The final task result in Lifecycle Workflows is decided by what the callback payload sends in operationStatus. Set it dynamically so successful function runs return completed, and failed function runs return failed.
In your callback HTTP body, it should look like this:

When you are done, select Publish at the top of the window.
Please note that if you are reusing my Shared Mailbox script, you will need to provide the managed identity of the Azure Function with read permissions to the Azure DevOps repo where the .JSON is stored.
Wrapping up and testing
To test, navigate to the Lifecycle Workflow that has your custom task extension task and select Run on demand. Select a user and run it. You should then be able to see the status of the task in the Workflow history shortly after it completes.

If you have Application Insights enabled on the Function App (which I recommend), you can also trace each function execution with KQL. This gives you a lot more detail than what you see in the Lifecycle Workflow history.
Bonus
If your scripts reference environment variables like tenant ID or client ID via $env:, those go in the Function App – navigate to Settings and select Environment variables – the equivalent of Automation account variables. Any values you had stored there need to be recreated here before the scripts will run correctly.