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
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
public class FunctionsToAppHttpClientFactory
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly FunctionsToAppTokenService _functionsToAppTokenService;
private readonly ILogger<FunctionsToAppHttpClientFactory> _logger;
public FunctionsToAppHttpClientFactory(IHttpClientFactory httpClientFactory, FunctionsToAppTokenService functionsToAppTokenService, ILogger<FunctionsToAppHttpClientFactory> logger)
{
_httpClientFactory = httpClientFactory;
_functionsToAppTokenService = functionsToAppTokenService;
_logger = logger;
}
public async Task<HttpClient> CreateClientAsync(Functions function)
{
_logger.LogInformation("Creating HTTP client for function: {Function}", function);
// Create an http client and set the connection base address
var client = _httpClientFactory.CreateClient(function.ToString());
var accessToken = await _functionsToAppTokenService.GetApiTokenAsync(function);
_logger.LogInformation("Token acquired, setting authorization header");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return client;
}
}
FunctionsToAppTokenService.cs
public class FunctionsToAppTokenService
{
private readonly IConfidentialClientApplication _app;
private readonly IConfiguration _configuration;
private readonly ILogger<FunctionsToAppTokenService> _logger;
private readonly ClientCredentialsFlowService _clientCredentialsFlow;
public FunctionsToAppTokenService(IConfiguration configuration, ILogger<FunctionsToAppTokenService> logger,
ClientCredentialsFlowService clientCredentialsFlow)
{
_configuration = configuration;
_logger = logger;
_clientCredentialsFlow = clientCredentialsFlow;
var tenantId = configuration["AzureAd:TenantId"];
// Initialize MSAL client
// Use the credentials of the functions app not the Blazor WASM app
_app = ConfidentialClientApplicationBuilder
.Create(_configuration["AzureAd:ClientId"])
.WithClientSecret(_configuration["AzureAd:ClientSecret"])
.WithAuthority($"https://login.microsoftonline.com/{tenantId}/")
.Build();
}
public async Task<string> GetApiTokenAsync(Functions function)
{
var accessToken = await _clientCredentialsFlow.GetFromCacheAsync(function.ToString());
if (accessToken != null)
{
_logger.LogInformation("Cached token found. Expires: {ExpiresIn} ({ExpiresKind}), Current UTC: {UtcNow}, Comparison: {IsValid}",
accessToken.ExpiresIn,
accessToken.ExpiresIn.Kind,
DateTime.UtcNow,
accessToken.ExpiresIn > DateTime.UtcNow);
if (accessToken.ExpiresIn > DateTime.UtcNow)
{
return accessToken.AccessToken;
}
}
var newAccessToken = await GetApiTokenClientAsync();
_clientCredentialsFlow.AddToCache(function.ToString(), newAccessToken);
return newAccessToken.AccessToken;
}
private async Task<ClientCredentialsFlowService.AccessTokenResult> GetApiTokenClientAsync()
{
try
{
var scopes = new[] { _configuration["BlazorWasmApp:Scopes"]};
_logger.LogInformation("Requesting token with scope: '{Scope}'", scopes[0]);
var authResult = await _app.AcquireTokenForClient(scopes).ExecuteAsync();
if (authResult == null)
{
throw new CroweException("Failed to acquire access token - null result");
}
// Decode the token to see what Azure AD actually returned
try
{
var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
var jsonToken = handler.ReadJwtToken(authResult.AccessToken);
catch (Exception ex)
{
_logger.LogError(ex, "Failed to decode JWT token from Azure AD");
}
_logger.LogInformation("Successfully acquired access token");
return new ClientCredentialsFlowService.AccessTokenResult
{
AccessToken = authResult.AccessToken,
ExpiresIn = authResult.ExpiresOn.DateTime
};
}
catch (MsalServiceException ex)
{
_logger.LogError(ex, "MSAL service exception: {ErrorCode} - {Message}", ex.ErrorCode, ex.Message);
throw new CroweException($"Authentication failed: {ex.ErrorCode} - {ex.Message}");
}
}
}
ClientCredentialsFlowService.cs
public class ClientCredentialsFlowService
{
private readonly IDistributedCache _cache;
private const int CACHE_EXPIRATION_DAYS = 1;
public ClientCredentialsFlowService(IDistributedCache cache)
{
_cache = cache;
}
public void AddToCache(string key, AccessTokenResult accessTokenItem)
{
var options = new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromDays(CACHE_EXPIRATION_DAYS));
var serializedItem = JsonSerializer.Serialize(accessTokenItem);
_cache.SetStringAsync(key, serializedItem, options);
}
public async Task<AccessTokenResult?> GetFromCacheAsync(string key)
{
var item = await _cache.GetStringAsync(key);
return item != null ? JsonSerializer.Deserialize<AccessTokenResult>(item) : null;
}
public class AccessTokenResult
{
public string AccessToken { get; set; } = string.Empty;
public DateTime ExpiresIn { get; set; }
}
public class AccessTokenItem
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = string.Empty;
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("token_type")]
public string TokenType { get; set; } = string.Empty;
[JsonPropertyName("scope")]
public string Scope { get; set; } = string.Empty;
}
}
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
public class WebhookService
{
private readonly string _environment;
private readonly ILogger<WorkflowWebhookService> _logger;
private readonly FunctionsToAppHttpClientFactory _functionsToAppHttpClientFactory;
private readonly string _appUrl;
public WorkflowWebhookService( IConfiguration configuration, ILogger<WorkflowWebhookService> logger, FunctionsToAppHttpClientFactory functionsToAppHttpClientFactory)
{
_logger = logger;
_functionsToAppHttpClientFactory = functionsToAppHttpClientFactory;
_appUrl = configuration.GetValue<string>("BlazorWasmApp:AppUrl");
}
public async Task ProcessWebhookCall(string body)
{
var responseModel = await ProcessWebhookAsync(body);
var message = responseModel.Message;
var functionsToAppClient = await _functionsToAppHttpClientFactory.CreateClientAsync(Functions.Workflows);
await functionsToAppClient.PostAsJsonAsync($"{_appUrl}/api/notifications", message);
}
private async Task<WebhookCallbackModel> ProcessWebhookAsync(string body)
{
var data = JsonSerializer.Deserialize<WebhookCallbackModel>(body);
if (data is null)
{
throw new CroweException(
statusCode: HttpStatusCode.Unauthorized,
message: "Failed to deserialize webhook response");
}
return data;
}
}
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
[ApiController]
[Route("api/notifications")]
public class SignalRNotificationsController : ControllerBase
{
private readonly ILogger<SignalRNotificationsController> _logger;
private readonly AzureSignalRNotificationService _azureSignalRNotificationService;
public SignalRNotificationsController(ILogger<SignalRNotificationsController> logger, AzureSignalRNotificationService azureSignalRNotificationService)
{
_logger = logger;
_azureSignalRNotificationService = azureSignalRNotificationService;
}
[HttpPost]
[Authorize(Policy = "ValidateAccessWorkflowsAppPolicy", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> ReceiveNotification([FromBody] NotificationModel notification)
{
_logger.LogInformation("Received task notification: {TaskId} - {Status}",
notification.TaskId, notification.Status);
await _azureSignalRNotificationService
.SendStatusUpdateAsync(
notification.UserOid,
notification.TaskId,
notification.Status,
notification.Message);
return Ok();
}
}
public class NotificationModel
{
public string Status { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string UserOid { get; set; } = string.Empty;
public string TaskId { get; set; } = string.Empty;
}
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.