Implementation and deployment of Azure AD App-to-App Authentication Between Azure Functions and Blazor WebAssembly

A month ago, I worked on a client project that required integrating Azure Functions into their existing Blazor WebAssembly application architecture. The client wanted to connect to an external API that relied on webhooks, and Azure Functions provided an ideal separation layer between their main application and the external service.

Azure Functions offer excellent scalability for operationally intensive tasks, making them well-suited for handling webhook processing and other background operations.

A massive shout out to Damien Bowden and Marc Rufer from isolutions, for their help in creating this.

In this blog, I’ll walk through the complete implementation, covering:

  • Infrastructure provisioning using Terraform
  • Deployment automation with Azure DevOps Pipelines
  • Service-to-service authentication using Azure AD Client Credentials flow
  • Real-time updates from Functions to the Blazor WebAssembly app via Azure SignalR Service

The key challenge was establishing secure, non-interactive authentication between the Azure Functions and the Blazor WebAssembly application. This required implementing OAuth2 Client Credentials flow with Azure AD to enable the Functions app to securely communicate status updates back to the main application.

Much of this implementation is based on the foundational work by Damien. His comprehensive articles on Azure AD app-to-app authentication provided the technical foundation for this solution:

Microsoft Entra ID App-to-App security architecture

Implementing OAuth2 Client Credentials flow App-to-App security using Azure AD (non-interactive)

Implementing OAuth2 App-to-App security using Azure AD from a web app

Infrastructure provisioning using Terraform

main-functions.tf

# Resource Group for functions
resource "azurerm_resource_group" "rg_functions" {
name = "rg-functions"
location = "West Europe"
tags = merge(var.resource_group_tags, { description = "Azure functions resource group" })
}

resource "azuread_group" "role-rg-functions-contributor" {
display_name = format("ra-%s-contributor", azurerm_resource_group.rg_functions.name)
description = "Role group which grants contributor access to the Azure Functions resource group"
prevent_duplicate_names = true
security_enabled = true
members = [
data.azurerm_client_config.current.object_id, # Your current user Azure ObjectId
azuread_service_principal.azuredevops.object_id # Azure DevOps pipeline service principal
]
lifecycle {
# apart from setting initially; do not flag changes in members and owners as state change
ignore_changes = [members, owners]
}
}

resource "azuread_group" "perm-rg-functions-contributor" {
display_name = format("pm-%s-contributor", azurerm_resource_group.rg_functions.name)
description = "Permission group granting contributor rights to the group"
members = [azuread_group.role-rg-functions-contributor.id]
prevent_duplicate_names = true
security_enabled = true
lifecycle {
# apart from setting initially; do not flag changes in owners as state change
ignore_changes = [owners]
}
}

resource "azurerm_role_assignment" "azuredevops-rg-functions-contributor" {
scope = azurerm_resource_group.rg_functions.id
role_definition_name = "Contributor"
principal_id = azuread_service_principal.azuredevops.id
}

resource "azurerm_service_plan" "functions_appplan" {
name = "appplan-functions"
resource_group_name = azurerm_resource_group.rg_functions.name
location = var.default_location
os_type = "Linux"
sku_name = "Y1" #THIS IS CRITICAL TO ENSURE DYNAMIC UPSCALING
}

resource "azurerm_storage_account" "sa_functions" {
# Place to store the functions app(s) codebase(s)
name = "functionssa"
resource_group_name = azurerm_resource_group.rg_functions.name
location = "West Europe"
account_tier = "Standard"
account_replication_type = "LRS"
min_tls_version = "TLS1_2"
tags = merge(var.service_tags, {
description = "Functions Runtime Storage"
})
}

A critical infrastructure problem I encountered was that Azure Function Apps with Consumption (dynamic scaling) plans cannot be deployed to Resource Groups that previously hosted other App Services or Function Apps with service plan types.

This limitation stems from how Azure maps application hosting plans to underlying infrastructure pools. When a Resource Group contains r different service plan SKUs (Consumption vs. App Service plans), Azure’s resource allocation conflicts prevent new deployments.

So I created a separate resource group for new dynamic functions app different application types:

  • One RG for the main Blazor WebAssembly app (App Service Plan)
  • Another RG for Azure Functions (Consumption Plan)

Read more at : https://learn.microsoft.com/en-us/answers/questions/1538122/deployment-issue-with-azure-app-service-with-terra

main-functionsapp.tf

resource "azurerm_linux_function_app" "functionsapp" {
name = "functionsapp"
resource_group_name = azurerm_resource_group.rg_functions.name
location = "West Europe"

storage_account_name = azurerm_storage_account.sa_functions.name
storage_account_access_key = azurerm_storage_account.sa_functions.primary_access_key
service_plan_id = azurerm_service_plan.functions_appplan.id
https_only = true

tags = merge(var.service_tags, { description = "Functions App" })

identity {
type = "SystemAssigned"
}

site_config {
application_stack {
dotnet_version = "8.0"
use_dotnet_isolated_runtime = "true"
}
application_insights_connection_string = azurerm_application_insights.appi.connection_string

health_check_path = "/api/health"
health_check_eviction_time_in_min = 10

http2_enabled = true
}

app_settings = {
# Azure AD Configuration for the FUNCTIONS API
"AzureAd__TenantId" = var.tenant_id
"AzureAd__ClientId" = azuread_application.functionsaadapp.client_id
"AzureAd__Instance" = "https://login.microsoftonline.com/"
"AzureAd__Authority" = "https://login.microsoftonline.com/${var.tenant_id}"

"BlazorWasmApp__ClientId" = azuread_application.aadapp.client_id
"BlazorWasmApp__AppUrl" = var.system_domain_uri
"BlazorWasmApp__Scopes" = "api://BlazorWasmApp-${var.stage}/.default",
"BlazorWasmApp__TokenEndpoint" = "https://login.microsoftonline.com/${var.tenant_id}/oauth2/v2.0/token"

# Key Vault Configuration
"AzureKeyVaultEndpoint" = azurerm_key_vault.kv.vault_uri

# Application Insights
"APPLICATIONINSIGHTS_CONNECTION_STRING" = azurerm_application_insights.appi.connection_string

WEBSITE_RUN_FROM_PACKAGE = ""
WEBSITE_MOUNT_ENABLED = "true"
}

lifecycle {
ignore_changes = [
app_settings["WEBSITE_RUN_FROM_PACKAGE"],
app_settings["WEBSITE_MOUNT_ENABLED"]
]
}
}

resource "azurerm_role_assignment" "functions-app-kv-reader-ra" {
scope = azurerm_key_vault.kv.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_linux_function_app.functionsapp.identity[0].principal_id
}


resource "azuread_application" "functionsaadapp" {
display_name = "Functions AAD App"
identifier_uris = ["api://functionsaadapp"]
sign_in_audience = "AzureADMyOrg"

api {
requested_access_token_version = 2
}

# Required_resource_access in azuread_application defines the API permissions
# that this application needs to access other Azure services or Microsoft Graph APIs.
# It's essentially the "permissions manifest" for the app.
# Request permission to call the main Blazor Wasm app

required_resource_access {
# Functions app REQUESTS the permission
resource_app_id = azuread_application.aadapp.client_id
resource_access {
id = random_uuid.random-uuid-app-role-functions.result # The app role ID from main app
type = "Role"
}
}
}

resource "azuread_service_principal" "functions-app-sp" {
client_id = azuread_application.functionsaadapp.client_id
}

# Grant the calling app (functions) permission to access the target app (BlazorWasmApp)
resource "azuread_app_role_assignment" "aadapproleassignment-functions-app" {
app_role_id = random_uuid.random-uuid-app-role-functions.result
principal_object_id = azuread_service_principal.-functions-app-sp.object_id # Calling App
resource_object_id = azuread_service_principal.aadapp-sp.object_id # Target App
}

resource "azuread_application_password" "functionsapppwd" {
display_name = "apppwd"
application_id = azuread_application.functionsaadapp.id
end_date_relative = "17520h"
}

resource "azurerm_key_vault_secret" "functionsapppwd-secret" {
key_vault_id = azurerm_key_vault.kv.id
name = "FunctionsApp--ClientSecret"
value = azuread_application_password.functionsapppwd.value
}

main-aad.tf

resource "azuread_application" "aadapp" {
display_name = "Blazor WASM AAD Application"
identifier_uris = ["api://blazorwasmapp"]
sign_in_audience = "AzureADMyOrg"
api {
requested_access_token_version = 2
}

# Other configurations...

# This is the app role that the functions app will be assigned to
app_role {
allowed_member_types = ["Application"]
description = "Allow access to main Blazor Wasm application"
display_name = "BlazorWasmAccess"
enabled = true
id = random_uuid.random-uuid-app-role-functions.result
value = "BlazorWasmAccess"
}
}

Deployment with Azure DevOps Pipelines

functionsapp.yml

name: FunctionsApp$(Date:yyyyMMdd)$(Rev:r)
trigger:
batch: true
branches:
include:
- main
paths:
include:
- src

variables:
isMainBranch: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]
artifactName: "FunctionsApp"
buildProjects: "src/Functions/FunctionsApp.csproj"
DOTNET_CLI_TELEMETRY_OPTOUT: true
NUGET_PACKAGES: "$(Pipeline.Workspace)/.nuget/packages"

pool:
vmImage: ubuntu-latest

stages:
- stage: Build
displayName: Build and Package
dependsOn: []
jobs:
- template: jobs-build-functions.yml
parameters:
artifactName: $(artifactName)
buildProjects: $(buildProjects)


- stage: DeployProd
displayName: Deploy to Prod
dependsOn: Build
condition: and(succeeded(), eq(variables.isMainBranch, true))
jobs:
- template: jobs-deploy-functions.yml
parameters:
artifactName: $(artifactName)
environment: Production
environmentCode: Prod
serviceConnection: azuredevops-service-connection
functionsAppName: functionsapp
keyVaultName: kv

jobs-build-functions.yml

parameters:
- name: buildProjects
type: string

- name: publishProjects
type: string

- name: buildConfiguration
type: string
default: "Release"

- name: artifactName
type: string
default: "FunctionsApp"

jobs:
- job: Build
displayName: Build Azure Functions
pool:
vmImage: ubuntu-latest

steps:
# Get .NET SDK
- task: UseDotNet@2
displayName: Get DotNet SDK
inputs:
packageType: sdk
useGlobalJson: true


# Build solution
- task: DotNetCoreCLI@2
displayName: Build Azure Functions
inputs:
command: build
projects: ${{ parameters.buildProjects }}
arguments: "--configuration ${{ parameters.buildConfiguration }} --property:VersionSuffix=$(Build.BuildNumber)"


# Publish functions app
- task: DotNetCoreCLI@2
displayName: Publish Azure Functions
inputs:
command: publish
projects: ${{ parameters.publishProjects }}
publishWebProjects: false
arguments: "--configuration ${{ parameters.buildConfiguration }} --property:VersionSuffix=$(Build.BuildNumber) --output $(Build.ArtifactStagingDirectory)/publish"
zipAfterPublish: true


- publish: $(Build.ArtifactStagingDirectory)/publish
artifact: ${{ parameters.artifactName }}
displayName: 'Publish Function App artifact'

jobs-deploy-functions.yml

parameters:
- name: "artifactName"
type: string
- name: "environment"
type: string
- name: "environmentCode"
type: string
- name: "serviceConnection"
type: string
- name: "functionsAppName"
type: string
- name: "keyVaultName"
type: string

jobs:

- deployment: Deploy
displayName: "Deploy ${{parameters.functionsAppName}}"
environment: ${{parameters.environment}}
strategy:
runOnce:
deploy:
steps:
- checkout: self
- download: current
displayName: Artifacts - Download function app artifact
artifact: ${{ parameters.artifactName }}

- task: AzureFunctionApp@2
displayName: Deploy Azure Function App
inputs:
azureSubscription: ${{parameters.serviceConnection}}
appType: functionAppLinux
appName: ${{parameters.functionsAppName}}
package: $(Pipeline.Workspace)/${{ parameters.artifactName }}/**/*.zip

I use Workload Identity Federation for my pipelines, for more information I recommend checking out Marc Rufer’s blog posts about it:

[HOWTO] Automate Terraform execution in Azure DevOps YAML pipeline

[HOWTO] Create Azure DevOps Service Connections with authentication method Workload Identity Federation using Terraform

Service-to-service authentication using Azure AD Client Credentials flow

I had a very torrid time here, as connecting the two apps in a non interactive manner was proving more difficult than I had anticipated.
Essentially the calling functions application successfully acquires a token with all expected claims (including roles, azp, azpacr), but when the token reaches the target Blazor Wasm app, the claims collection was empty. The token acquisition logs show 16 claims including the required app role on the functions side, but the main app’s JWT Bearer authentication results in zero claims being available to the authorization handlers. 

Having searched endlessly and worked poor Claude to death, I finally fell upon this : https://github.com/AzureAD/microsoft-identity-web/issues/547

The original application had:

// Authentication for user tokens
services.AddMicrosoftIdentityWebAppAuthentication(configuration)
    .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
    .AddMicrosoftGraph("https://graph.microsoft.com/v1.0", initialScopes)
    .AddDistributedTokenCaches();

This handles interactive user login via OpenID Connect
and set OpenID Connect as the primary authentication method. It was designed for the users accessing the web application through browsers, to authenticate with.

What the functions app need to authenticate with was: Service Authentication

// Authentication for client tokens
services.AddAuthentication()
     .AddMicrosoftIdentityWebApi(configuration.GetSection("AzureAd"))
     .EnableTokenAcquisitionToCallDownstreamApi();

This handles JWT Bearer tokens from service principals (Azure Functions)
by adding JWT Bearer as a named authentication scheme.

By having both authentication way, I was able to resolve conflicting authentication schemes.
Instead of having the Azure Functions app sent JWT Bearer tokens, and the Blazor Wasm app trying to process them through the web app authentication pipeline (OpenID Connect), which expected interactive user sessions rather than service principal tokens.

This configuration created separate, non-interfering pathways:

  • Browser requests → OpenID Connect → User authentication
  • API requests with Bearer tokens → JWT Bearer → Service authentication

To tell the app to use the service authentication all I need was to add:

[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]

to the desired endpoints.

Getting the token

FunctionsToAppHttpClientFactory.cs

FunctionsToAppTokenService.cs

ClientCredentialsFlowService.cs

FunctionsToAppHttpClientFactory.cs

FunctionsToAppTokenService.cs

ClientCredentialsFlowService.cs

should all be registered in the Functions app program.cs ONLY, as scoped.


I also encountered some token caching issues where previously cached tokens with incorrect configuration values were being reused after fixing the Azure AD settings. The cached tokens contained invalid claim values from the earlier misconfiguration period, causing silent authentication failures. Rebooting the Redis cache cleared these problematic cached tokens and forced fresh token acquisition with the correct configuration values.

Webhook Processing Flow

WebhookService.cs

When the external API sends a webhook notification, the Azure Functions app processes the incoming data and forwards status updates to the main Blazor WebAssembly application using the secure authentication pattern described above.
The Functions app acts as an intermediary layer, transforming webhook payloads into structured notifications that can be consumed by the Blazor WebAssembly application’s real-time update system.

Real-time updates from Functions to the Blazor WebAssembly app via Azure SignalR Service

Before getting started with this, take a look at my post on how to add real-time notifications in Blazor WebAssembly with Azure SignalR Service. Once you follow those steps, the next phase becomes super easy.

SignalRNotificationsController.cs

Add the controller to your Blazor WASM app, and the notification model into a shared directory accessible by the Blazor app and the Function app.

With all this in place, you have enabled real-time push notifications from the Functions app to connected Blazor WebAssembly clients, completing the end-to-end webhook processing pipeline!

Conclusion

Implementing secure app-to-app authentication between Azure Functions and Blazor WebAssembly applications requires careful consideration of both infrastructure design and authentication architecture.

Infrastructure Separation: Azure Function Apps with Consumption plans require dedicated Resource Groups to avoid service plan conflicts. This separation also aligns with resource lifecycle management best practices.

Authentication Pipeline Design: The dual authentication setup using both AddMicrosoftIdentityWebAppAuthentication and AddMicrosoftIdentityWebApi enables a single application to handle both interactive user sessions and service-to-service authentication without conflicts.

Token Management: Proper token caching with appropriate expiration handling is crucial for performance, but cache invalidation becomes critical when configuration changes occur. Always ensure cached tokens align with current configuration values.

Configuration Management: Environment-specific configuration mismatches can cause subtle authentication failures. Verify that Azure AD application settings match exactly between development and production environments.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top