Posted in:

Whenever you use an Azure Functions trigger or binding, you need to give Azure Functions the ability to connect to the target service. For example, if you want to bind to blob storage, you'd typically have a connection string to a Storage Account in your application settings (and there's one there by default called AzureWebJobsStorage which can be convenient to use for small and simple Azure Functions applications). And similarly for services like Azure Service Bus, Azure Cosmos DB, or Azure SQL Database, you'd put a connection string in your Application Settings.

However, from a security perspective, many people are trying to move away from connection strings. That's because that if the connection string was leaked, then whoever had it would be able to use it to gain access to the data in the service.

A better approach is to use "identity-based connections". With this approach, your Azure Functions application has a "managed identity", and that identity is granted access to the services that you are binding to. This means that there are no secrets to be leaked in your Function App.

It is unfortunately, a little bit more difficult to set up, so in this article, I want to guide you through the steps I went through to create an Azure Functions app with it. I'll be using the Azure CLI and PowerShell for most of it. The whole project is available here on GitHub, including the powershell for deployment.

Step 1 - Create the Azure Functions App

First of all, you need to create a Function App, which requires you to first log in to the Azure CLI, select a subscription to use, create a resource group and a storage account to use.

# Set variables
$subscription = "mysubscription"
$resourceGroupName = "xyz-funcs-identity"
$location = "westeurope"
$functionAppName = "xyz-funcs-identity-01"
$storageAccountName = "xyzfuncsidentity01"
$serviceBusNamespace = "xyzfuncsidentity01"

az account set --subscription $subscription

# Create a resource group
az group create --name $resourceGroupName --location $location

# Create a storage account
az storage account create --name $storageAccountName `
    --resource-group $resourceGroupName `
    --location $location `
    --sku Standard_LRS `
    --allow-blob-public-access false

Creating the Azure Function app itself is fairly straightforward. The key for our purposes is that the --assign-identity flag ensures that our function app has a managed identity.

# Create an Azure Functions app with managed identity (and will automatically create App Insights)
az functionapp create --name $functionAppName `
    --resource-group $resourceGroupName `
    --consumption-plan-location $location `
    --functions-version 4 `
    --runtime dotnet-isolated `
    --runtime-version 8 `
    --os-type Windows `
    --storage-account $storageAccountName `
    --assign-identity

Step 2 - Grant the Function App's managed identity access to the storage account

The exact roles that you need to grant your managed identity depend on exactly what you are doing, and whether you are using the built-in AzureWebJobsStorage account which Azure Functions uses for its own purposes, or your own storage account. In my demo, I am using AzureWebJobsStorage and I also want to be able to use Durable Functions which means we need privileges to use queues and tables as well as blobs.

The official Microsoft Learn documentation has a helpful table that you can use to decide what roles you need. Obviously, the "principle of least privilege" should be applied and so you should try to avoid granting more roles than are necessary.

For the scope, we get the resource identifier of the storage account with az storage account show and then apply the roles with calls to az role assignment create.

$storageAccountId = az storage account show -n $storageAccountName `
    -g $resourceGroupName --query id --output tsv

# Grant managed identity access to the storage account
az role assignment create `
    --role "Storage Blob Data Contributor" `
    --assignee $principalId `
    --scope $storageAccountId

# other roles as recommended here to enable Durable Functions, Timer Triggers, Blob Triggers, etc.:
# https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference?tabs=blob&pivots=programming-language-csharp#connecting-to-host-storage-with-an-identity
az role assignment create `
    --role "Storage Blob Data Owner" `
    --assignee $principalId `
    --scope $storageAccountId

az role assignment create `
    --role "Storage Account Contributor" `
    --assignee $principalId `
    --scope $storageAccountId

az role assignment create `
    --role "Storage Queue Data Contributor" `
    --assignee $principalId `
    --scope $storageAccountId

az role assignment create `
    --role "Storage Table Data Contributor" `
    --assignee $principalId `
    --scope $storageAccountId

Step 3 - Get rid of the connection string

By default, our Function App has been set up with a connection string called AzureWebJobsStorage in its application settings. We can delete that, and replace it with the specially named AzureWebJobsStorage__accountName setting, which points only to the storage account name. This essentially tells Azure Functions that we are using identity-based connections. Note that in some circumstances there may be additional settings you need to add which are described here although for our use case, this single setting is all that is needed.

# Get rid of the connection string
az functionapp config appsettings delete `
    --name $functionAppName `
    --resource-group $resourceGroupName `
    --setting-names "AzureWebJobsStorage"

# set up the account name for Azure Functions to use managed identities
az functionapp config appsettings set `
    --name $functionAppName `
    --resource-group $resourceGroupName `
    --settings AzureWebJobsStorage__accountName=${storageAccountName}

Now at this point, we've actually already done everything necessary for our Functions App to use Azure Storage triggers and bindings to the AzureWebJobsStorage connection, without needing a connection string. And if you wanted to bind to a different storage account, then you'd just go through exactly the same steps to grant permissions to that storage account, and add another application setting with the __accountName suffix.

However, we'd also like to use this same identity-based connection technique for Azure Service Bus, so let's do that next.

Step 4 - Create Service Bus Namespace and queue

Obviously we need an Azure Service Bus namespace, so I'm using az servicebus namespace create for that. And I've also chosen to create the queue up front with az servicebus queue create.

# Create Azure Service Bus Namespace
az servicebus namespace create `
    --name $serviceBusNamespace `
    --resource-group $resourceGroupName `
    --location $location

# Create a queue
az servicebus queue create --name "orders" `
    --namespace-name $serviceBusNamespace `
    --resource-group $resourceGroupName

Step 5 - Grant the managed identity access to the Service Bus

And now we need to grant the Azure Function app's managed identity access to the Service Bus namespace. Again, the scope is just the resource id of the Service Bus namespace. And the roles I'm granting it are "Azure Service Bus Data Sender" and "Azure Service Bus Data Receiver" to allow posting messages to queues and receiving them.

$serviceBusNamespaceId = az servicebus namespace show `
    --name $serviceBusNamespace `
    --resource-group $resourceGroupName `
    --query id `
    --output tsv

# Grant managed identity access to the Azure Service Bus namespace
az role assignment create `
    --role "Azure Service Bus Data Sender" `
    --assignee $principalId `
    --scope $serviceBusNamespaceId

# Grant managed identity ability to receive messages from Azure Service Bus namespace
az role assignment create `
    --role "Azure Service Bus Data Receiver" `
    --assignee $principalId `
    --scope $serviceBusNamespaceId

Step 6 - Set the Service Bus connection up in Azure Functions App Settings

Finally, we need to add an Application Setting that lets Azure Functions know which Service Bus namespace we're connecting to. Instead of creating an Application Setting called ServiceBusConnection with the connection string, we create one called ServiceBusConnection__fullyQualifiedNamespace which just has the domain name of the Service Bus namespace as its value.

# get the service bus fully qualified namespace
$serviceBusNamespaceFqdn = az servicebus namespace show `
    --name $serviceBusNamespace `
    --resource-group $resourceGroupName `
    --query serviceBusEndpoint `
    --output tsv

# get the domain name only from a URL
$serviceBusNamespaceDomain = [System.Uri]::new($serviceBusNamespaceFqdn).Host

# set up the managed identity connection to service bus
# As explained here: https://learn.microsoft.com/en-us/dotnet/api/overview/azure/microsoft.azure.webjobs.extensions.servicebus-readme?view=azure-dotnet#identity-based-authentication
az functionapp config appsettings set `
    --name $functionAppName `
    --resource-group $resourceGroupName `
    --settings ServiceBusConnection__fullyQualifiedNamespace=${serviceBusNamespaceDomain}

Step 7 - Using the connections in function code

The great thing about these identity-based connections is that they require no code changes at all, compared to code that used connection strings directly.

For example, this storage queue-triggered function will work if there is an Application Setting called AzureWebJobsStorage with a connection string, but it also works for our application which only has a AzureWebJobsStorage__accountName setting.

[Function(nameof(QueueTrigger1))]
public async Task Run(
    [QueueTrigger("orders", Connection = "AzureWebJobsStorage")] 
    QueueMessage message,

And the same applies for Service Bus triggered functions. This function just refers to the ServiceBusConnection setting, and Azure Functions knows it is an identity-based connection because of the ServiceBusConnection__fullyQualifiedNamespace Application Setting.

[Function(nameof(ServiceBusQueueTrigger))]
public void ServiceBusQueueTrigger(
    [ServiceBusTrigger("orders", Connection = "ServiceBusConnection")] 
    ServiceBusReceivedMessage message)

The full code for these sample functions can be found in the example demo application.

Step 8 - Deploy and Testing

If you want to try this out with the demo app, then you can use the func azure functionapp publish command to build the code and publish it to Azure. And the az functionapp keys list command enables you to access the function code to grant you permission to call the HTTP-triggered endpoints that can be used to test the functionality.

# publish the function app
func azure functionapp publish $functionAppName

# get the function invocation key
$functionKey = az functionapp keys list `
    --name $functionAppName `
    --resource-group $resourceGroupName `
    --query "functionKeys.default" `
    --output tsv

# invoke web request to the create-order function
Invoke-WebRequest -Uri "https://$functionAppName.azurewebsites.net/api/createorder?code=$functionKey"

Summary

In summary, Azure Functions offers support for identity-based connections for the majority of bindings, and for most production applications, I recommend that you consider this a security best-practice and configure it by default. The only complex thing is making sure that you assign the right roles to the right identity, with the right scope, and I hope my instructions above have made that a bit easier for you.

Want to learn more about how easy it is to get up and running with Azure Functions? Be sure to check out my Pluralsight courses Azure Functions Fundamentals and Microsoft Azure Developer: Create Serverless Functions