Logo
Securing Azure Functions for Power Apps with Entra ID and Terraform

Securing Azure Functions for Power Apps with Entra ID and Terraform

Introduction

Connecting Power Apps to custom backend APIs is common in enterprise environments. What is less common is doing it properly — with identity-based security, no shared secrets for API access, and infrastructure that is fully reproducible.

This post walks through a template I built for exactly that: a C# Azure Function protected by Entra ID and EasyAuth v2, consumed by a Power Apps custom connector via OAuth 2.0, with all infrastructure and identity configuration automated in Terraform.

The full source code — including all Terraform configurations, the Function App, and the OpenAPI specification — is available as a public repository on my GitHub: https://github.com/philippgalliker/azure-functions-powerapps-connector.

C# was a deliberate choice. The Function runs inside the Microsoft ecosystem, it serves the Power Platform, and the entire identity stack is Entra ID. Keeping the backend in .NET 10 (isolated worker model) means first-class SDK support, minimal friction, and consistency with the platform it integrates into.


Motivation

A common way to secure Azure Function APIs for Power Apps is with function keys — generate a key, paste it into the custom connector, and you have a working integration. This is a valid starting point, but as requirements around security and maintainability grow, it comes with trade-offs worth considering:

  • Function keys are shared secrets. Anyone who has the key can call the API. There is no user identity, no audit trail, and revoking access means rotating the key for all consumers.
  • There is no group-based access control. You cannot restrict which users in your tenant may call the API.
  • Keys can end up hardcoded or stored in less secure locations — connector configurations, environment variables, or even source control.
  • Infrastructure drift becomes harder to manage when app registrations, permissions, and security groups are configured manually.

This template takes a different approach: identity-based access at every layer, automated end-to-end with Terraform.


Architecture overview

The data flow is straightforward:

  1. A Power Apps canvas app calls the custom connector.
  2. The connector triggers an OAuth 2.0 authorization code flow against Entra ID.
  3. Entra ID issues a v2 access token with group claims to the signed-in user — only if the user is a member of the designated security group.
  4. The token is sent to the Azure Function.
  5. EasyAuth v2 validates the token at the platform level (issuer, audience, group membership) before the function code executes.
  6. If valid, the function returns a JSON response. If not, the caller gets HTTP 401.

Power Apps → Custom Connector → Entra ID (OAuth 2.0) → Azure Function (EasyAuth v2) → Response

The key architectural decision: no function keys, no shared secrets for API access. Authentication is purely identity-based. The function code itself uses AuthorizationLevel.Anonymous because EasyAuth already rejected anything unauthenticated before the code even runs.


Zero Trust

Zero Trust means "never trust, always verify" — no implicit trust based on network location, no assumed safety because a request originates from within a corporate network or a known service.

This template implements Zero Trust across three principles:

PrincipleHow this repository implements it
Verify explicitlyEvery request must carry a valid Entra ID token. EasyAuth v2 validates issuer, audience, and group membership before code executes.
Least privilege accessapp_role_assignment_required = true — only security group members can even obtain a token. Everyone else is rejected at the identity layer.
Assume breachNo function keys, no shared secrets for API access — authentication is purely identity-based. Managed identity for storage access (no access keys). The connector client secret rotates annually via time_rotating.

In concrete terms: even if an attacker has network access to the Function App URL, they cannot call the API without a valid token issued by your tenant, for the correct audience, and with the correct group membership claim. There is no key to steal, no secret to extract from environment variables, and no bypass via anonymous access.


Defense in Depth

Defense in Depth means layering multiple independent security controls so that no single failure grants access. In this template, an attacker would need to:

be in the correct Entra ID tenant → be a member of the security group → hold a valid token with the right audience → have the right group claim → communicate over HTTPS

Compromise of any single layer alone does not grant access.

LayerControlWhat it stops
1. Entra ID app registrationsign_in_audience = "AzureADMyOrg"Users from other tenants cannot authenticate (B2B guests in your tenant can still sign in — downstream layers gate their access)
2. Security group gateapp_role_assignment_required = trueInternal users outside the group cannot obtain a token
3. EasyAuth v2 (platform)unauthenticated_action = "Return401"Unauthenticated requests never reach your code
4. Allowed groups checkallowed_groups in EasyAuth configToken must contain the specific security group GUID
5. Audience validationallowed_audiences in EasyAuth configTokens minted for other APIs are rejected
6. HTTPS onlyhttps_only = true, minimum_tls_version = "1.2"No cleartext traffic
7. No anonymous storageallow_nested_items_to_be_public = false + managed identityNo key leakage, no public blob access
8. Admin consent pre-grantedDelegated permission grant via TerraformUsers cannot consent to rogue apps — only the declared connector has delegated access

Each of these layers is configured declaratively in Terraform. None of them rely on manual portal clicks that could be forgotten or misconfigured.


What Terraform creates

The infrastructure is split into two Terraform projects: infra/bootstrap/ for remote state storage and infra/main/ for everything else.

The bootstrap project — remote state

Before Terraform can manage infrastructure, it needs a place to store its state file. This is the classic chicken-and-egg problem: you cannot store state remotely before the remote backend exists.

The infra/bootstrap/ project solves this with a dedicated Terraform configuration that uses a local backend on purpose. It creates:

  • A Resource Group for state storage
  • A Storage Account with HTTPS-only, TLS 1.2, no public blob access
  • A Blob Container (tfstate) for the state file
  • An RBAC role assignmentStorage Blob Data Contributor for the deploying user

The bootstrap state itself is small, contains no secrets, and is safe to commit to the repository for reproducibility.

Why Terraform for bootstrapping (not a CLI script)

A common alternative is a Bash script with az storage account create. Terraform is a better fit here:

  • Declarative and idempotent — safe to re-run, handles drift automatically
  • Destroyableterraform destroy cleans up everything
  • Reviewable — the bootstrap infrastructure is code-reviewed like all other IaC
  • Consistent tooling — one tool for all infrastructure, no separate scripting layer

What remote state solves

A remote backend stores Terraform's state file in a secure, central location instead of on a developer's laptop:

  • Encrypted at rest in Azure Blob Storage
  • Access-controlled via RBAC (Entra ID auth, no storage access keys)
  • Shared safely across team members and CI/CD pipelines
  • Single source of truth — no conflicting local copies

The main project references this backend via backend.tfvars:

# infra/main/providers.tf
backend "azurerm" {
  use_azuread_auth = true
  key              = "function-connector.tfstate"
}

Entra ID authentication (use_azuread_auth = true) means no storage access key is stored anywhere — not in config files, not in CI variables.

The main project — infrastructure and identity

main provisions everything needed for the secure Function-to-Power-Apps flow:

ResourcePurpose
Resource GroupContainer for all Azure resources
Storage Account + Blob ContainerFlex Consumption deployment storage, managed identity auth
Log Analytics WorkspaceCentralized log collection
Application InsightsMonitoring and diagnostics
App Service Plan (FC1)Flex Consumption serverless hosting
Azure Function AppC# API endpoint, HTTPS-only, EasyAuth v2 configured
Entra ID app registration (Function API)Protects the Function, issues v2 tokens with Function.Access role
Entra ID security groupGates access — only members get tokens
App role assignmentFunction.Access role assigned to the security group
Entra ID app registration (Connector)OAuth 2.0 auth code flow for the Power Apps custom connector
Admin consent grantPre-consented user_impersonation — no per-user consent prompts
Client secret with annual rotationtime_rotating resource triggers rotation every 365 days
Service principals (Function API + Connector)Enterprise app identities backing the app registrations
Storage RBAC role assignments (Function App)Storage Blob Data Owner, Storage Queue Data Contributor, Storage Table Data Contributor for the managed identity
Resource provider registrationMicrosoft.AlertsManagement — required for smart detector alert rules
Smart Detector Alert Rule + Action GroupAuto-created by Azure when deploying Application Insights — explicitly declared for clean lifecycle management

The EasyAuth v2 configuration in Terraform

The most critical block in the entire configuration is the auth_settings_v2 on the Function App. This is where all the identity checks converge:

# EasyAuth v2 – Entra ID (Azure AD) as the sole auth provider.
auth_settings_v2 {
  auth_enabled           = true
  require_authentication = true
  unauthenticated_action = "Return401"
  default_provider       = "azureactivedirectory"
  runtime_version        = "~1"

  login {
    # Store auth tokens server-side so the function can call downstream
    # APIs using the logged-in user's token if needed.
    token_store_enabled = true
  }

  active_directory_v2 {
    client_id            = azuread_application.function_api.client_id
    tenant_auth_endpoint = "https://login.microsoftonline.com/${data.azurerm_client_config.current.tenant_id}/v2.0"

    # Only accept tokens whose audience matches our application ID URI.
    allowed_audiences = [local.function_app_audience]

    # Restrict access to members of this Entra security group.
    # Requires the "groups" claim in the token (configured on the app registration).
    allowed_groups = [azuread_group.function_access.object_id]
  }
}

This single block enforces: authentication required, unauthenticated requests get 401, only tokens from your tenant are accepted, only tokens for the correct audience are accepted, and only tokens with the correct group claim pass through. All before your C# code executes a single line.

Note that the allowed_groups check requires the groups claim to be present in the access token. This is configured on the Function API app registration via an optional_claims block that requests the groups claim in access tokens — without it, EasyAuth would have no group information to evaluate.

The token_store_enabled = true setting stores auth tokens server-side. This is useful if the function needs to call downstream APIs (e.g. Microsoft Graph) on behalf of the signed-in user using their delegated token.

The connector app registration

The connector registration is a separate Entra ID application with:

  • sign_in_audience = "AzureADMyOrg" — same tenant only
  • Redirect URIs for Power Platform's consent endpoints (global, Europe, preview)
  • A declared required_resource_access for the user_impersonation scope on the Function API
  • Admin consent pre-granted via azuread_service_principal_delegated_permission_grant — users never see a consent prompt

The client secret is managed with time_rotating:

resource "time_rotating" "connector_secret_rotation" {
  rotation_days = 365
}

resource "azuread_application_password" "connector" {
  application_id = azuread_application.connector.id
  rotate_when_changed = {
    rotation = time_rotating.connector_secret_rotation.id
  }
}

When rotation triggers, terraform apply generates a new secret and the old one expires. The new secret must then be updated in the Power Apps custom connector.


The Azure Function

The function itself is intentionally minimal — a .NET 10 isolated worker with a single HTTP-triggered endpoint:

public sealed partial class HelloFunction(ILogger<HelloFunction> logger)
{
    [Function("Hello")]
    public async Task<HttpResponseData> RunAsync(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData request,
        CancellationToken cancellationToken)
    {
        LogProcessing(logger);

        var response = request.CreateResponse(HttpStatusCode.OK);
        await response.WriteAsJsonAsync(
            new { message = "Hello, world!" },
            cancellationToken).ConfigureAwait(false);
        return response;
    }

    [LoggerMessage(Level = LogLevel.Information, Message = "Processing Hello request")]
    private static partial void LogProcessing(ILogger logger);
}

AuthorizationLevel.Anonymous is deliberate. It means the Functions runtime does not require a function key. EasyAuth v2 handles all authentication and authorization at the platform level — unauthenticated requests are rejected with HTTP 401 before this code runs.

This is a template. The Hello endpoint is a placeholder. Replace it with your actual business logic — the security model stays the same regardless of what the function does.

An OpenAPI specification (swagger.json) is included for the Power Apps custom connector import. It defines the OAuth 2.0 security scheme with placeholders that are filled from Terraform outputs after deployment.

Managed identity for storage — and the AzureWebJobsStorage caveat

The Function App uses a system-assigned managed identity for all storage access instead of connection strings. Terraform assigns three RBAC roles to the managed identity:

RoleWhy it is needed
Storage Blob Data OwnerRead/write deployment blobs and manage host lock leases
Storage Queue Data ContributorInternal queue communication between the Functions runtime components
Storage Table Data ContributorTimer trigger bookkeeping

There is one important caveat: Azure auto-generates an AzureWebJobsStorage connection string with an empty AccountKey. If both the connection string and the managed identity configuration exist, the connection string wins — causing HTTP 403 errors at runtime. The Terraform configuration explicitly clears this:

app_settings = {
  "AzureWebJobsStorage"              = ""
  "AzureWebJobsStorage__accountName" = azurerm_storage_account.sa.name
}

Setting AzureWebJobsStorage to an empty string forces the runtime to fall back to the __accountName form, which uses managed identity. This is a common gotcha with Flex Consumption and managed identity that is easy to miss.


The custom connector setup

The connector is the only manual step. After deploying the infrastructure and function code, you import the OpenAPI file in the Power Apps maker portal and configure the OAuth 2.0 security settings using values from Terraform outputs.

Custom connector OpenAPI import

The security tab is where the Entra ID integration is configured:

FieldSource
Client IDterraform output connector_client_id
Client secretterraform output -raw connector_client_secret
Authorization URLLeave empty — clear any pre-filled value. Power Apps builds it automatically from the Tenant ID.
Tenant IDExtract the GUID from terraform output connector_auth_url
Resource URLterraform output function_app_audience
Enable on-behalf-of loginfalse (default) — not needed; the connector authenticates directly against the Function API
Scopeterraform output connector_oauth_scope
Custom connector security tab OAuth configuration

After saving, Power Apps generates a redirect URL. This URL must be added back to the Terraform configuration (connector_redirect_uri variable in terraform.tfvars) and applied with terraform apply. This registers the redirect URI on the connector app registration so the OAuth flow can complete — without it, Entra ID will reject the callback and the connector cannot obtain a token.

To verify the end-to-end flow, switch to the Test tab in the custom connector editor. Click + New connection, sign in with a user who is a member of the Entra security group, select the Hello operation, and click Test operation.

Custom connector test success

A user who is not a member of the security group will get a 401 — the token is issued, but EasyAuth rejects it because the required group claim is missing:


Where to find things in the Azure Portal and Entra ID

One of the benefits of Terraform is that infrastructure is documented as code. But you will still need to verify and inspect resources in the portal. Here is where to find everything that Terraform creates.

Azure Portal (portal.azure.com)

ResourcePortal path
Resource GroupHome → Resource groups → (name from terraform output resource_group_name)
Function AppHome → Resource groups → [rg] → (name from terraform output fa_name)
Function App — AuthenticationFunction App → Settings → Authentication
Function App — IdentityFunction App → Settings → Identity → System assigned
Function App — ConfigurationFunction App → Settings → Environment variables
Storage AccountHome → Resource groups → [rg] → (name from terraform output sa_name)
Storage Account — RBACStorage Account → Access Control (IAM) → Role assignments
App Service PlanHome → Resource groups → [rg] → (name from terraform output asp_name)
Application InsightsHome → Resource groups → [rg] → (Application Insights resource)
Log Analytics WorkspaceHome → Resource groups → [rg] → (Log Analytics resource)
Smart Detector Alert RuleHome → Resource groups → [rg] → (alert: "Failure Anomalies - ...")
Action GroupHome → Resource groups → [rg] → (action group: "Application Insights Smart Detection")
Azure Portal Function App authentication settings

Entra ID Admin Center (entra.microsoft.com)

ResourcePortal path
Function API app registrationIdentity → Applications → App registrations → All applications → (display name from function_app_display_name, defaults to <fa_name>-api)
Function API — Expose an APIApp registration → Manage → Expose an API (shows user_impersonation scope and Application ID URI)
Function API — App rolesApp registration → Manage → App roles (shows Function.Access role)
Function API — Enterprise app (Service Principal)Identity → Applications → Enterprise applications → (same display name) → Properties (shows app_role_assignment_required = Yes)
Connector app registrationIdentity → Applications → App registrations → All applications → (display name: <fa_name>-api-connector)
Connector — API permissionsApp registration → Manage → API permissions (shows user_impersonation with admin consent granted)
Connector — Certificates & secretsApp registration → Manage → Certificates & secrets → Client secrets
Connector — Redirect URIsApp registration → Manage → Authentication → Web redirect URIs
Security groupIdentity → Groups → All groups → (name from terraform output function_access_group_name)
Security group — MembersGroup → Manage → Members
Admin consent grantEnterprise applications → (connector service principal) → Security → Permissions
Entra ID app registration API permissions

Terraform and Azure auto-created resources (lifecycle caveat)

When you deploy Application Insights, Azure automatically creates standalone resources behind the scenes: a Smart Detector alert rule ("Failure Anomalies") and an action group ("Application Insights Smart Detection"). These are independent Azure resources — they are not sub-resources of Application Insights.

This causes a practical problem: terraform destroy cannot remove resources it does not know about. If these auto-created resources are not in Terraform state, they are left behind as orphans, and subsequent deployments or manual cleanup become messy.

The solution in this template is to explicitly declare them in Terraform:

resource "azurerm_monitor_action_group" "smart_detection" {
  name                = "Application Insights Smart Detection"
  resource_group_name = azurerm_resource_group.rg.name
  short_name          = "SmartDetect"
  # ...
}

resource "azurerm_monitor_smart_detector_alert_rule" "failure_anomalies" {
  name                = "Failure Anomalies - ${azurerm_application_insights.ai.name}"
  resource_group_name = azurerm_resource_group.rg.name
  detector_type       = "FailureAnomaliesDetector"
  # ...
}

Azure also silently attaches a hidden tag to the Application Insights resource. The lifecycle { ignore_changes = [tags] } block prevents perpetual diffs.

Best practice: model and manage all Azure auto-created side-resources in Terraform to maintain full lifecycle control and ensure clean teardown.


Where to go from here

This template is a starting point. A few directions to extend it:

  • Add real business logic. Replace the Hello endpoint with actual API operations — CRUD against Cosmos DB, calls to Microsoft Graph, file processing, whatever your Power App needs. The security model stays the same.
  • Connect to more Azure services. The Function App has a system-assigned managed identity. To grant it access to Key Vault, Cosmos DB, SQL Database, or other services, add azurerm_role_assignment resources referencing the managed identity's principal ID. No connection strings needed.
  • CI/CD pipeline. Add a GitHub Actions or Azure DevOps pipeline for terraform plan on pull requests and terraform apply on merge. The remote state backend already supports this — the pipeline authenticates with a service principal or workload identity federation.
  • Multiple environments. Use Terraform workspaces or separate tfvars files per environment (dev, staging, prod). The bootstrap storage account can hold multiple state files.
  • Private networking. Add VNet integration, private endpoints for the storage account, and restrict the Function App to internal traffic only. This adds a network perimeter on top of the identity-based controls.
  • Custom domain and API Management. Put Azure API Management in front of the Function App for rate limiting, request transformation, and a custom domain. The Entra ID authentication still applies end-to-end.
  • Monitoring and alerting. Application Insights and Log Analytics are already provisioned. Build KQL queries and alert rules for failed authentication attempts, latency spikes, or function errors.

Summary

This repository demonstrates that securing an Azure Function for Power Apps consumption does not require complex middleware or third-party tooling. The entire security model runs on Entra ID and Azure platform features:

  • EasyAuth v2 handles token validation at the platform level — no authentication code in the function.
  • Security groups control who can call the API — no key management.
  • Terraform automates everything — app registrations, role assignments, consent grants, infrastructure — so the security configuration is reviewable, reproducible, and destroyable.
  • Zero Trust and Defense in Depth are not abstract concepts here — they are concrete Terraform resources and configuration blocks.
  • The only manual step is the Power Apps custom connector setup, which requires the Power Apps maker portal. Everything else is terraform apply.