Integrate Power Apps with AI by fronting Azure OpenAI behind a secure .NET 8 isolated Azure Function, authenticated via Entra ID and deployed with Azure Bicep. Why this matters: you keep your Azure OpenAI keyless via Managed Identity, enforce RBAC, and provide a stable HTTPS endpoint for Power Apps using a custom connector.
The Problem
Developers need to call AI securely from Power Apps without exposing keys, while meeting enterprise requirements for RBAC, observability, and least-privilege. Manual wiring through the portal and ad hoc security checks lead to drift and risk.
Prerequisites
- Azure CLI 2.60+
- .NET 8 SDK
- Azure Developer CLI (optional) or Bicep CLI
- Owner or User Access Administrator on the target subscription
- Power Apps environment access for creating a custom connector
The Solution (Step-by-Step)
1) Deploy Azure resources with Bicep (Managed Identity, Function App, Azure OpenAI, RBAC, Easy Auth)
This Bicep template creates a Function App with system-assigned managed identity, Azure OpenAI with a model deployment, App Service Authentication (Easy Auth) enforced, and assigns the Cognitive Services OpenAI User role to the identity.
// main.bicep
targetScope = 'resourceGroup'
@description('Name prefix for resources')
param namePrefix string
@description('Location for resources')
param location string = resourceGroup().location
@description('Azure OpenAI model name, e.g., gpt-4o-mini')
param aoaiModelName string = 'gpt-4o-mini'
@description('Azure OpenAI model version for your region (see Azure docs for supported versions).')
param aoaiModelVersion string = '2024-07-18' // Tip: Validate the correct version via az cognitiveservices account list-models
var funcName = '${namePrefix}-func'
var aoaiName = '${namePrefix}-aoai'
var hostingPlanName = '${namePrefix}-plan'
var appInsightsName = '${namePrefix}-ai'
var storageName = toLower(replace('${namePrefix}st${uniqueString(resourceGroup().id)}', '-', ''))
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
}
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
name: appInsightsName
location: location
kind: 'web'
properties: {
Application_Type: 'web'
}
}
resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: hostingPlanName
location: location
sku: {
name: 'Y1' // Consumption for Functions
tier: 'Dynamic'
}
}
resource func 'Microsoft.Web/sites@2023-12-01' = {
name: funcName
location: location
kind: 'functionapp'
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: plan.id
siteConfig: {
appSettings: [
{ name: 'AzureWebJobsStorage', value: storage.listKeys().keys[0].value }
{ name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsights.properties.ConnectionString }
{ name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' }
{ name: 'FUNCTIONS_WORKER_RUNTIME', value: 'dotnet-isolated' }
// Enable Easy Auth enforcement via config below (authSettingsV2)
]
http20Enabled: true
minimumTlsVersion: '1.2'
ftpsState: 'Disabled'
cors: {
allowedOrigins: [
// Add your Power Apps domain if needed; CORS is typically managed by the custom connector
]
}
}
httpsOnly: true
}
}
// Enable App Service Authentication (Easy Auth) with Entra ID and enforce authentication.
resource auth 'Microsoft.Web/sites/config@2023-12-01' = {
name: '${func.name}/authsettingsV2'
properties: {
globalValidation: {
requireAuthentication: true
unauthenticatedClientAction: 'Return401'
}
identityProviders: {
azureActiveDirectory: {
enabled: true
registration: {
// When omitted, system can use an implicit provider. For enterprise, specify a registered app:
// Provide clientId and clientSecretSettingName if using a dedicated app registration.
}
validation: {
// Audience (App ID URI or application ID). Power Apps custom connector should request this audience.
// Replace with your API application ID URI when using a dedicated AAD app registration.
allowedAudiences: [
'api://<your-app-id>'
]
}
login: {
disableWWWAuthenticate: false
}
}
}
platform: {
enabled: true
runtimeVersion: '~1'
}
login: {
tokenStore: {
enabled: true
}
}
}
}
// Azure OpenAI account
resource aoai 'Microsoft.CognitiveServices/accounts@2023-05-01' = {
name: aoaiName
location: location
kind: 'OpenAI'
sku: {
name: 'S0'
}
properties: {
customSubDomainName: toLower(aoaiName)
publicNetworkAccess: 'Enabled'
}
}
// Azure OpenAI deployment - ensure model/version are valid for your region
resource aoaiDeployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = {
name: 'chat-${aoaiModelName}'
parent: aoai
properties: {
model: {
format: 'OpenAI'
name: aoaiModelName
version: aoaiModelVersion
}
capacities: [
{
capacity: 1
capacityType: 'Standard'
}
]
}
}
// Assign RBAC: Cognitive Services OpenAI User role to the Function's managed identity
// Built-in role: Cognitive Services OpenAI User (Role ID: 5e0bd9bd-7ac1-4c9e-8289-1b01f135d4a8)
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(aoai.id, func.identity.principalId, 'CognitiveServicesOpenAIUserRole')
scope: aoai
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7ac1-4c9e-8289-1b01f135d4a8')
principalId: func.identity.principalId
principalType: 'ServicePrincipal'
}
}
output functionAppName string = func.name
output openAiEndpoint string = 'https://${aoai.name}.openai.azure.com/'
output openAiDeployment string = aoaiDeployment.name
Tip: To find the correct model version for your region, run: az cognitiveservices account list-models --name <aoaiName> --resource-group <rg> --output table.
2) Implement a secure .NET 8 isolated Azure Function with DI and Managed Identity
The function validates Entra ID via Easy Auth headers, performs input validation, calls Azure OpenAI using DefaultAzureCredential, and returns a minimal response. AuthorizationLevel is Anonymous because App Service Authentication is enforcing auth at the edge; the code still verifies identity defensively.
// Program.cs
using System.Text.Json;
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Azure.Functions.Worker.Extensions.OpenApi.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var host = new HostBuilder()
.ConfigureFunctionsWebApplication(builder =>
{
// Add global middleware if needed (e.g., correlation, exception handling)
builder.UseMiddleware<GlobalExceptionMiddleware>();
})
.ConfigureServices((ctx, services) =>
{
var config = ctx.Configuration;
// Bind custom options
services.Configure<OpenAIOptions>(config.GetSection(OpenAIOptions.SectionName));
// Register OpenAI client using Managed Identity (no keys)
services.AddSingleton((sp) =>
{
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<OpenAIOptions>>().Value;
// Use DefaultAzureCredential to leverage Managed Identity in Azure
var credential = new DefaultAzureCredential();
return new OpenAIClient(new Uri(options.Endpoint), credential);
});
services.AddSingleton<IPromptService, PromptService>();
services.AddApplicationInsightsTelemetryWorkerService();
})
.ConfigureAppConfiguration(config =>
{
config.AddEnvironmentVariables();
})
.ConfigureLogging(logging =>
{
logging.AddConsole();
})
.Build();
await host.RunAsync();
file sealed class GlobalExceptionMiddleware : IFunctionsWorkerMiddleware
{
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
try
{
await next(context);
}
catch (Exception ex)
{
var logger = context.GetLogger<GlobalExceptionMiddleware>();
logger.LogError(ex, "Unhandled exception");
// Avoid leaking internals; return generic error with correlation ID
var invocationId = context.InvocationId;
var http = await context.GetHttpRequestDataAsync();
if (http is not null)
{
var response = http.CreateResponse(System.Net.HttpStatusCode.InternalServerError);
await response.WriteStringAsync($"Request failed. CorrelationId: {invocationId}");
context.GetInvocationResult().Value = response;
}
}
}
}
file sealed class OpenAIOptions
{
public const string SectionName = "OpenAI";
public string Endpoint { get; init; } = string.Empty; // e.g., https://<aoaiName>.openai.azure.com/
public string Deployment { get; init; } = string.Empty; // e.g., chat-gpt-4o-mini
}
// Service abstraction to keep function logic clean
file interface IPromptService
{
Task<string> CreateChatResponseAsync(string userInput, CancellationToken ct);
}
file sealed class PromptService(OpenAIClient client, Microsoft.Extensions.Options.IOptions<OpenAIOptions> opts, ILogger<PromptService> logger) : IPromptService
{
private readonly OpenAIClient _client = client;
private readonly OpenAIOptions _opts = opts.Value;
private readonly ILogger<PromptService> _logger = logger;
public async Task<string> CreateChatResponseAsync(string userInput, CancellationToken ct)
{
// Basic input trimming and minimal length check
var prompt = (userInput ?? string.Empty).Trim();
if (prompt.Length < 3)
{
return "Input too short.";
}
// Create chat completion with minimal system prompt
var req = new ChatCompletionsOptions()
{
DeploymentName = _opts.Deployment,
Temperature = 0.2f,
MaxTokens = 256
};
req.Messages.Add(new ChatRequestSystemMessage("You are a concise assistant.")); // Guardrail
req.Messages.Add(new ChatRequestUserMessage(prompt));
_logger.LogInformation("Calling Azure OpenAI deployment {Deployment}", _opts.Deployment);
var resp = await _client.GetChatCompletionsAsync(req, ct);
var msg = resp.Value.Choices.Count > 0 ? resp.Value.Choices[0].Message.Content[0].Text : "No response.";
return msg ?? string.Empty;
}
}
// HttpFunction.cs
using System.Net;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Api;
public sealed class ChatRequest
{
// Strongly typed DTO for request validation
public string Prompt { get; init; } = string.Empty;
}
public sealed class ChatFunction(IPromptService promptService, ILogger<ChatFunction> logger)
{
// Simple helper to read Easy Auth principal header safely
private static ClaimsPrincipal? TryGetPrincipal(HttpRequestData req)
{
// Easy Auth sets x-ms-client-principal as Base64 JSON
if (!req.Headers.TryGetValues("x-ms-client-principal", out var values)) return null;
var b64 = values.FirstOrDefault();
if (string.IsNullOrWhiteSpace(b64)) return null;
try
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
using var doc = JsonDocument.Parse(json);
var claims = new List<Claim>();
if (doc.RootElement.TryGetProperty("claims", out var arr))
{
foreach (var c in arr.EnumerateArray())
{
var typ = c.GetProperty("typ").GetString() ?? string.Empty;
var val = c.GetProperty("val").GetString() ?? string.Empty;
claims.Add(new Claim(typ, val));
}
}
return new ClaimsPrincipal(new ClaimsIdentity(claims, "EasyAuth"));
}
catch
{
return null;
}
}
[Function("chat")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "chat")] HttpRequestData req,
FunctionContext context)
{
var principal = TryGetPrincipal(req);
if (principal is null)
{
// Defense in depth: App Service auth should already block, but we double-check.
var unauth = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauth.WriteStringAsync("Authentication required.");
return unauth;
}
// Basic content-type check and bound read limit
if (!req.Headers.TryGetValues("Content-Type", out var ct) || !ct.First().Contains("application/json", StringComparison.OrdinalIgnoreCase))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteStringAsync("Content-Type must be application/json.");
return bad;
}
// Safe body read
using var reader = new StreamReader(req.Body);
var body = await reader.ReadToEndAsync();
ChatRequest? input;
try
{
input = JsonSerializer.Deserialize<ChatRequest>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteStringAsync("Invalid JSON.");
return bad;
}
if (input is null || string.IsNullOrWhiteSpace(input.Prompt))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteStringAsync("Prompt is required.");
return bad;
}
var loggerScope = new Dictionary<string, object> { ["UserObjectId"] = principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value ?? "unknown" };
using (logger.BeginScope(loggerScope))
{
try
{
var result = await promptService.CreateChatResponseAsync(input.Prompt, context.CancellationToken);
var ok = req.CreateResponse(HttpStatusCode.OK);
await ok.WriteAsJsonAsync(new { response = result });
return ok;
}
catch (Exception ex)
{
logger.LogError(ex, "Error generating chat response");
var err = req.CreateResponse(HttpStatusCode.BadGateway);
await err.WriteStringAsync("AI request failed. Try again later.");
return err;
}
}
}
}
// appsettings.json (for local debug)
{
"OpenAI": {
"Endpoint": "https://<your-aoai-name>.openai.azure.com/",
"Deployment": "chat-gpt-4o-mini"
}
}
Tip: When running locally, ensure you are logged in with Azure CLI (az login) so DefaultAzureCredential can acquire a token. Assign yourself Cognitive Services OpenAI User on the AOAI resource for testing.
3) Enforce Azure AD (Easy Auth) - configuration example
The Bicep above enables Easy Auth and returns 401 for unauthenticated calls. If you maintain settings as JSON, you can also use an ARM style configuration:
// Example snippet (appsettings for site config via ARM/Bicep-style) ensures:
// - requireAuthentication: true
// - allowedAudiences must match the audience used by your client (Power Apps custom connector)
{
"properties": {
"authSettingsV2": {
"platform": { "enabled": true },
"globalValidation": {
"requireAuthentication": true,
"unauthenticatedClientAction": "Return401"
},
"identityProviders": {
"azureActiveDirectory": {
"enabled": true,
"validation": {
"allowedAudiences": [ "api://<your-app-id>" ]
}
}
}
}
}
}
Tip: For most enterprise setups, register a dedicated App Registration for the Function API and reference its Application ID URI in allowedAudiences. Keep consent and scopes explicit.
4) Create a Power Apps Custom Connector
- In Power Apps, open Solutions and create a new Custom Connector.
- Set the Host to your Function URL (e.g., https://<func-name>.azurewebsites.net).
- Define the POST /chat operation that accepts a JSON body { "prompt": "..." } and returns { "response": "..." }.
- Security: Choose OAuth 2.0 (Azure Active Directory). Set Audience to match allowedAudiences (api://<your-app-id>). Supply Tenant ID and Client ID as provided by your admin.
- Test the connector. You should receive HTTP 200 with the AI response. Unauthorized calls return 401.
5) Add client-side validation in Power Apps (Power Fx)
Before invoking the connector, validate inputs in the app to fail fast and reduce server load.
// Example Power Fx for a button's OnSelect
If(
IsBlank(Trim(txtPrompt.Text)) || Len(Trim(txtPrompt.Text)) < 3,
Notify("Please enter at least 3 characters.", NotificationType.Error),
Set(
aiResult,
YourConnector.chat(
{
prompt: Trim(txtPrompt.Text)
}
).response
)
);
// Display result in a Label: Text = aiResult
Tip: Add input length limits and debouncing for multi-keystroke actions. For read-only UI against data, prefer server-side pagination and AsNoTracking() on the API layer where EF Core is involved.
Best Practices & Security
Identity and RBAC
- Use Managed Identity for the Function App; never store secrets. The sample uses DefaultAzureCredential.
- Assign the Cognitive Services OpenAI User role to the Function’s identity on the Azure OpenAI resource. This allows API calls without exposing keys.
- Enforce Azure AD with Easy Auth at the platform level and verify identity in code for defense in depth.
Least Privilege and Network
- Restrict access with the minimum RBAC scope necessary. Avoid granting Contributor on the subscription.
- Enable HTTPS only and TLS 1.2+. Disable FTP/FTPS where possible.
- Consider Private Endpoints for Azure OpenAI and Functions behind an Application Gateway/WAF for enterprise networks.
Validation, Error Handling, and Reliability
- Validate input on both client (Power Apps) and server (Function). The code enforces content-type and schema shape.
- Handle exceptions using middleware and return generic messages with correlation IDs to avoid leaking internals.
- Implement circuit breakers and retries for downstream calls with transient fault policies if you add HTTP dependencies.
Observability
- Use Application Insights. Log prompt sizes and latency, not raw sensitive content.
- Sample KQL to investigate errors:
// Requests by operation
requests
| where url endswith "/chat"
| summarize count() by resultCode
// Exceptions with correlation
exceptions
| where operation_Name == "chat"
| project timestamp, type, problemId, outerMessage, operation_Id
// Latency p95
requests
| where url endswith "/chat"
| summarize p95(duration) by bin(timestamp, 1h)
Cost and Abuse Controls
- Apply rate limiting at the API Management layer if exposing broadly.
- Set MaxTokens and Temperature conservatively; monitor usage with budgets and alerts.
- Implement content filters as needed using Azure AI Content Safety.
Summary
- Power Apps integrates cleanly with AI by calling a secure .NET 8 Azure Function that uses Managed Identity to access Azure OpenAI.
- Security is enforced with Azure AD (Easy Auth) and RBAC, specifically the Cognitive Services OpenAI User role.
- IaC with Bicep, defensive coding, and Application Insights deliver repeatable, production-grade operations.