Short answer: host the script in an Azure Functions 4.x app on PowerShell 7.6, turn on a system-assigned managed identity, grant that identity the Microsoft Graph application permissions it needs, and keep any remaining secrets in Key Vault references. No admin password is stored in the function app.
Updated: October 2026
This guide has been completely rewritten. The original version used the Azure Functions 1.x experimental PowerShell runtime, the MSOnline module and a delegated admin password stored in the function app. All of those are now retired or unsafe. This version uses Azure Functions 4.x on PowerShell 7.6, a managed identity, Microsoft Graph and Azure Key Vault, so there are no passwords to store or rotate.
When we first moved our Microsoft 365 admin scripts out of scheduled tasks and into Azure Functions, the only way to make them run unattended was to give the function a username and password. We encrypted the password, but the key that decrypted it lived in the same function app, and the account it protected could not use MFA. That design worked for years. It is now a liability: the runtime is out of support, the MSOnline module no longer works, and an admin password sitting in a web app is exactly what attackers look for. This guide shows how we build the same kind of automation today, using a timer-triggered function that reports unused Microsoft 365 licences as the worked example.
Who this is for
- IT administrators and MSP engineers who run Microsoft 365 PowerShell on a schedule and want it off servers and laptops.
- Anyone still running a function app built from our older Azure Functions guides (they all relied on the same stored-credential pattern).
- Business owners who want to know what “good” looks like when a provider says they automate your Microsoft 365 reporting. The short version: no stored admin passwords, least-privilege permissions, and logs you can audit.
What changed
Every layer of the original design has been retired or replaced:
- Azure Functions 1.x is out of support. Support for the 1.x runtime ended on 14 September 2026, and Azure Functions now supports only version 4.x of the runtime (Azure Functions runtime versions overview).
- The MSOnline module no longer works. Microsoft retired MSOnline on 30 May 2025, so
Connect-MsolServiceand other Msol cmdlets fail (Azure AD PowerShell migration FAQ). Microsoft Graph PowerShell is the replacement (Upgrade from Azure AD PowerShell to Microsoft Graph PowerShell). - Stored passwords are the wrong tool for unattended scripts. Microsoft recommends against signing in with a username and password from code because it is incompatible with MFA. For automation running in Azure, Microsoft recommends a managed identity (Microsoft identity platform and the OAuth 2.0 ROPC flow). Managed identities are also not affected by Microsoft’s mandatory MFA enforcement, and Microsoft says user identities aren’t recommended for automation (Mandatory MFA for Azure and admin portals).
- Exchange Online Remote PowerShell is gone. Since October 2023, Exchange Online PowerShell uses REST API connections through the Exchange Online PowerShell V3 module instead of Remote PowerShell and Basic authentication (About the Exchange Online PowerShell V3 module).
- DAP has been replaced by GDAP for partners. Microsoft is replacing delegated admin privileges (DAP) with granular delegated admin privileges (GDAP), which give least-privilege, time-bound access to customer tenants (GDAP frequently asked questions). See the multi-tenant notes near the end of this guide.
- The five minute limit that pushed us onto an App Service plan no longer applies. The original guide paid for an always-on App Service plan to avoid the old Consumption plan timeout. The Flex Consumption plan has a default timeout of 30 minutes and no enforced maximum, while still billing only for execution time (Azure Functions hosting options).
How the new design fits together
- Function app on the Flex Consumption plan, running PowerShell 7.6 on Azure Functions 4.x.
- System-assigned managed identity on the function app. Microsoft Entra ID issues it tokens, so there is no secret in your code or settings.
- Microsoft Graph application permissions granted directly to that identity, limited to what the script reads.
- Exchange Online (optional) through
Connect-ExchangeOnline -ManagedIdentity, with a read-only Microsoft Entra role. - Azure Key Vault for anything that genuinely is a secret, such as a webhook URL, surfaced to the function as a Key Vault reference in app settings.
- Application Insights for logs, failures and run history.
Choosing a hosting plan: Flex Consumption or Consumption?
Microsoft now describes Flex Consumption as the recommended serverless hosting plan for Azure Functions, and labels the original Consumption plan as legacy (Flex Consumption plan hosting, hosting options). Use Flex Consumption for new Microsoft 365 automation unless you have a hard dependency on Windows.
| Consideration | Flex Consumption (recommended) | Consumption (legacy) |
|---|---|---|
| Operating system | Linux only | Windows (Linux Consumption is retiring, with a final date of 30 September 2028) |
| PowerShell versions | PowerShell 7.6 and 7.4 | PowerShell 7.6 on Windows. Linux Consumption gets no new language versions. |
| Default and maximum timeout | 30 minutes, no enforced maximum | 5 minutes, maximum 10 |
| PowerShell modules | Bundle them with your code. Managed dependencies are not supported. | Managed dependencies or bundled modules |
| Time zone setting for timers | Not supported, schedules run in UTC | WEBSITE_TIME_ZONE supported on Windows |
| Billing | Execution time and executions, plus optional always ready instances | Execution time and executions |
Sources: Flex Consumption plan, Azure Functions hosting options, PowerShell developer reference for Azure Functions and Timer trigger for Azure Functions.
Which PowerShell version?
Azure Functions 4.x offers PowerShell 7.6 and PowerShell 7.4, and Microsoft lists 10 November 2026 as the end of support date for PowerShell 7.4 (Azure Functions runtime versions overview). Build new apps on PowerShell 7.6. It also matters for Exchange: ExchangeOnlineManagement 3.10.0 and later require PowerShell 7.6, while versions 3.5.0 to 3.9.2 run on PowerShell 7.4 (Exchange Online PowerShell module requirements). If you have an existing app that must stay on 7.4 for a few more weeks, pin ExchangeOnlineManagement to 3.9.2 or earlier and plan the upgrade now.
Managed dependencies or bundled modules?
Azure Functions can install modules for you from the PowerShell Gallery (the managed dependencies feature, driven by requirements.psd1), or you can ship modules inside a Modules folder with your code. Microsoft’s guidance is that bundling gives you more control, removes the dependency on the PowerShell Gallery at runtime, avoids regressions from automatic upgrades, works on Flex Consumption and is recommended for other Linux plans. Managed dependencies are not supported on Flex Consumption at all (PowerShell developer reference: dependency management).
We bundle every module and pin its version. Updates then happen when you choose to test and redeploy, not when a new module version lands on a Tuesday night. Bundle only what each function needs. For the licence report below that is just Microsoft.Graph.Authentication, not the full Microsoft.Graph roll-up module.
Prerequisites
- An Azure subscription in the same Microsoft Entra tenant as the Microsoft 365 tenant you want to report on.
- Rights to create resources and role assignments in a resource group: Contributor plus Role Based Access Control Administrator, scoped to that resource group, ideally with a condition that limits it to assigning Key Vault Secrets User. Avoid Owner, which can assign any Azure role.
- The Microsoft Entra Privileged Role Administrator role, activated just in time, to grant Microsoft Graph application permissions to the managed identity. Cloud Application Administrator and Application Administrator can’t grant Microsoft Graph app roles.
- PowerShell 7.6 on your workstation, plus the Azure CLI. Azure Functions Core Tools is optional but makes deployment easier.
- The
Microsoft.Graph.Applicationsmodule on your workstation for the one-off permission script (Install-PSResource Microsoft.Graph.ApplicationsorInstall-Module Microsoft.Graph.Applications -Scope CurrentUser). If you’ll do the optional Exchange step, also installMicrosoft.Graph.Identity.Governance. Our Microsoft Graph PowerShell guide covers installing and connecting.
Role requirements are from Grant tenant-wide admin consent to an application and Managed identities for App Service and Azure Functions.
Step 1: Create the function app on Flex Consumption
Run the following in PowerShell 7 after signing in with az login. First check which regions currently offer Flex Consumption, because not every region does (Create and manage function apps in the Flex Consumption plan). Storage account names must be 3 to 24 lowercase letters and numbers, and the function app name must be globally unique. Run steps 1 to 4 and step 7 in the same PowerShell window, because later commands reuse the variables set here ($rg, $region, $storage, $appName, $kvName and $miObjectId). If you close the window, set them again before you continue.
# List regions that currently support Flex Consumption az functionapp list-flexconsumption-locations --output table # Set your own names and a supported region $rg = 'rg-m365-automation' $region = 'australiaeast' $storage = 'stm365automation01' $appName = 'func-m365-reports-contoso' $kvName = 'kv-m365-reports-contoso' az group create --name $rg --location $region az storage account create --name $storage --location $region --resource-group $rg --sku Standard_LRS --allow-blob-public-access false az functionapp create --resource-group $rg --name $appName --storage-account $storage --flexconsumption-location $region --runtime powershell --runtime-version 7.6
The last command creates the function app in a Flex Consumption plan and, by default, an Application Insights resource in the same resource group. Without always ready instances, the app only costs money while functions are actually running. Replace australiaeast with whichever supported region suits you.
Treat the storage account as part of the application. It holds host state and, on Flex Consumption, your deployment package. By default, deployments use the same storage account (AzureWebJobsStorage) and its connection string, so anyone with the account’s access keys, or a role that can list them, can replace your deployment package and run code as the function’s identity. Microsoft’s security best practice is to connect to storage with managed identities. Once the app is working, switch the deployment storage authentication to the managed identity (Configure deployment settings) and the host storage connection to an identity-based connection (identity-based connections tutorial), confirm the app still runs, then turn off shared key access with az storage account update --name $storage --resource-group $rg --allow-shared-key-access false. Test this sequence on a non-production app first.
Step 2: Turn on the system-assigned managed identity
$miObjectId = az functionapp identity assign --resource-group $rg --name $appName --query principalId --output tsv $miObjectId
The output is the object (principal) ID of the identity’s service principal in Microsoft Entra ID. Keep it for the next steps. A system-assigned identity is deleted when the function app is deleted, and its permissions go with it, which suits a single-purpose automation app (Managed identity best practice recommendations).
Step 3: Grant Microsoft Graph application permissions to the identity
You can’t add API permissions to a managed identity in the Azure portal the way you can for an app registration, so grant them with Microsoft Graph PowerShell. The licence report needs two read-only application permissions:
| Permission | Why the script needs it |
|---|---|
LicenseAssignment.Read.All |
Least-privileged application permission for List subscribedSkus (purchased and assigned licence counts). |
User.Read.All |
Least-privileged application permission for List users, used to find sign-in-blocked accounts that still hold licences. |
Save this as Grant-GraphPermissionsToManagedIdentity.ps1 and run it on your workstation. It signs in interactively with MFA, keeps that sign-in to the current PowerShell window, looks up each permission by name rather than hard-coding IDs, skips any permission that is already assigned and signs out even if something fails. Run it with -WhatIf first to see exactly what it would grant, then run it again without -WhatIf.
#Requires -Version 7.4
#Requires -Modules Microsoft.Graph.Applications
<#
.SYNOPSIS
Grants Microsoft Graph application permissions (app roles) to a managed identity.
.NOTES
Author: Elliot Munro - GCIT (https://gcit.com.au)
Run as a Privileged Role Administrator. Interactive sign-in with MFA.
Supports -WhatIf, so you can see what it would grant before it changes anything.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string] $ManagedIdentityObjectId,
[string[]] $Permissions = @('LicenseAssignment.Read.All', 'User.Read.All')
)
$ErrorActionPreference = 'Stop'
# Process scope: the high-privilege token isn't cached for later PowerShell sessions
Connect-MgGraph -Scopes 'Application.Read.All', 'AppRoleAssignment.ReadWrite.All' -ContextScope Process -NoWelcome
try {
# Microsoft Graph's service principal in your tenant (the appId is the same in every tenant)
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$existing = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ManagedIdentityObjectId -All
foreach ($permission in $Permissions) {
$appRole = $graphSp.AppRoles | Where-Object {
$_.Value -eq $permission -and $_.AllowedMemberTypes -contains 'Application'
}
if (-not $appRole) {
throw "Microsoft Graph has no application permission named '$permission'."
}
$alreadyGranted = $existing | Where-Object {
$_.AppRoleId -eq $appRole.Id -and $_.ResourceId -eq $graphSp.Id
}
if ($alreadyGranted) {
Write-Host "Already granted: $permission"
continue
}
if ($PSCmdlet.ShouldProcess($ManagedIdentityObjectId, "Grant Microsoft Graph application permission $permission")) {
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ManagedIdentityObjectId -PrincipalId $ManagedIdentityObjectId -ResourceId $graphSp.Id -AppRoleId $appRole.Id | Out-Null
Write-Host "Granted: $permission"
}
}
}
finally {
Disconnect-MgGraph | Out-Null
}
# Preview first, then grant .\Grant-GraphPermissionsToManagedIdentity.ps1 -ManagedIdentityObjectId $miObjectId -WhatIf .\Grant-GraphPermissionsToManagedIdentity.ps1 -ManagedIdentityObjectId $miObjectId
Allow time for the change to apply. Managed identity tokens are cached by Azure for around 24 hours, and Microsoft says there is currently no way to force a refresh, so a permission you add today might not be visible to the function for several hours (Limitation of using managed identities for authorization). The method used here is the same one Microsoft documents in Assign a managed identity to an application role using PowerShell.
Step 4: Store secrets in Key Vault and reference them from app settings
A managed identity removes the Microsoft 365 credential entirely, but most automation still has one or two genuine secrets: an API key for a third-party system, or the URL of a Power Automate flow that receives the report. Those belong in Key Vault, not in code or plain app settings.
# Create a vault (new vaults use the Azure RBAC permission model by default) az keyvault create --name $kvName --resource-group $rg --location $region --enable-rbac-authorization true # Let the function app's identity read secret values, and nothing else $kvId = az keyvault show --name $kvName --resource-group $rg --query id --output tsv az role assignment create --assignee-object-id $miObjectId --assignee-principal-type ServicePrincipal --role 'Key Vault Secrets User' --scope $kvId
Then, in the Azure portal:
- Give yourself the Key Vault Secrets Officer role on the vault (just in time if you use Privileged Identity Management), open the vault, and add a secret named
report-webhook-url. Typing the value in the portal keeps it out of your shell history. - Open the function app, go to Settings > Environment variables > App settings, and add a setting named
REPORT_WEBHOOK_URLwith this value (use your vault name):@Microsoft.KeyVault(SecretUri=https://kv-m365-reports-contoso.vault.azure.net/secrets/report-webhook-url) - Save. Reopen the setting to confirm the reference shows as resolved.
Your code reads $env:REPORT_WEBHOOK_URL like any other setting and never sees the vault. Leaving the secret version out of the reference means rotation is picked up automatically, within 24 hours, because App Service caches resolved values and refetches them daily. Any configuration change to the app forces an immediate refetch (Use Key Vault references as app settings, Key Vault RBAC guide).
If the webhook is a Power Automate flow with an HTTP request trigger, its URL contains a shared access signature (the sig= value), so anyone holding the URL can start the flow, including posting a fake report to it. Treat it as a secret, have the flow check the incoming report’s shape before acting on it, and regenerate the key if it is ever exposed (Regenerate the SAS key used in HTTP trigger flows).
Check the trigger’s authentication setting. Microsoft is adding an authentication setting to the When an HTTP request is received trigger. New flows default to Any user in my tenant, which only accepts calls that carry a Microsoft Entra token, and Specific users in my tenant can be limited to a service principal’s object ID. The code in this guide sends no token, so it only works with Anyone, which Microsoft describes as the legacy, open setting. Requiring a token from the function’s managed identity is the stronger option, but we haven’t tested it here, so treat it as a change to test before you rely on it (Add OAuth authentication for HTTP request triggers).
Step 5: Build the function project
Create this folder structure on your workstation. If you started from a template, delete any requirements.psd1 file and any managedDependency section in host.json, because Flex Consumption doesn’t support managed dependencies.
M365Reports | - host.json | - profile.ps1 | - Modules | | - Microsoft.Graph.Authentication | - UnusedLicenceReport | | - function.json | | - run.ps1
host.json
This caps each run at 10 minutes (Microsoft recommends keeping a fixed upper bound) and keeps run results and your own log lines flowing to Application Insights (host.json reference, Configure monitoring for Azure Functions). The extension bundle range is the one Flex Consumption requires for non-C# apps.
{
"version": "2.0",
"functionTimeout": "00:10:00",
"logging": {
"logLevel": {
"default": "Information",
"Host.Aggregator": "Information",
"Host.Results": "Information"
}
},
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.0.0, 5.0.0)"
}
}
profile.ps1
The template profile signs in to Azure with the Az module on every cold start. This app doesn’t use Az, and Flex Consumption gives the app only 30 seconds to initialise, so keep the profile empty and connect inside each function instead (Flex Consumption considerations).
# profile.ps1 runs once per PowerShell worker on cold start. # Intentionally empty. Each function imports only the modules it needs and # connects with the managed identity when it runs.
Bundle the module
From the M365Reports folder, save the module into Modules. Pin the version you have tested by adding -Version with the exact number.
New-Item -ItemType Directory -Path .\Modules -Force | Out-Null Find-PSResource -Name Microsoft.Graph.Authentication -Repository PSGallery Save-PSResource -Name Microsoft.Graph.Authentication -Path .\Modules -Repository PSGallery -TrustRepository
When the app starts, the PowerShell worker adds the Modules folder to $env:PSModulePath. Never call Install-Module inside a function, because it runs on every invocation and slows everything down (PowerShell developer reference).
UnusedLicenceReport/function.json
Timer schedules use six-field NCRONTAB expressions and run in UTC. Flex Consumption doesn’t support the WEBSITE_TIME_ZONE setting, so convert the time yourself. 0 30 21 * * 0-4 fires at 21:30 UTC Sunday to Thursday, which is 7:30 am Monday to Friday in Brisbane (AEST, UTC+10, no daylight saving). Sydney and Melbourne move to UTC+11 in summer, so the report arrives an hour later in local time then.
{
"bindings": [
{
"name": "Timer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 30 21 * * 0-4"
}
]
}
Leave runOnStartup out. Microsoft warns against enabling it in production because it fires the function whenever the app starts or scales (Timer trigger for Azure Functions).
Step 6: The script
Save this as UnusedLicenceReport/run.ps1. It connects to Microsoft Graph with the managed identity, works out how many seats of each licence are paid for but unassigned, lists accounts that are blocked from signing in but still hold licences, writes a summary to Application Insights and, if you configured one, posts the report to your webhook.
<#
.SYNOPSIS
Azure Functions timer trigger: reports unused Microsoft 365 licences.
.DESCRIPTION
Connects to Microsoft Graph with the function app's system-assigned managed identity.
Reports purchased-but-unassigned seats per subscription, and sign-in-blocked
accounts that still hold licences. Logs to Application Insights and optionally
posts the report as JSON to REPORT_WEBHOOK_URL (a Key Vault reference).
.NOTES
Author: Elliot Munro - GCIT (https://gcit.com.au)
Runtime: Azure Functions 4.x, PowerShell 7.6, Flex Consumption
Graph application permissions: LicenseAssignment.Read.All, User.Read.All
Optional app settings:
IGNORED_SKUS Comma-separated skuPartNumber values to leave out of the report
REPORT_WEBHOOK_URL Key Vault reference to the URL that receives the JSON report
#>
param($Timer)
$ErrorActionPreference = 'Stop'
if ($Timer.IsPastDue) {
Write-Warning 'UnusedLicenceReport is running later than scheduled.'
}
$ignoredSkus = @()
if ($env:IGNORED_SKUS) {
$ignoredSkus = $env:IGNORED_SKUS -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
}
Import-Module Microsoft.Graph.Authentication
try {
Connect-MgGraph -Identity -NoWelcome
# 1. Paid-for seats versus assigned seats, per subscription
$skuUri = 'https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuId,skuPartNumber,appliesTo,capabilityStatus,consumedUnits,prepaidUnits'
$skus = (Invoke-MgGraphRequest -Method GET -Uri $skuUri).value
$skuNames = @{}
foreach ($sku in $skus) {
$skuNames[[string]$sku.skuId] = $sku.skuPartNumber
}
$unusedLicences = foreach ($sku in $skus) {
if ($sku.appliesTo -ne 'User') { continue }
if ($ignoredSkus -contains $sku.skuPartNumber) { continue }
$purchased = [int]$sku.prepaidUnits.enabled
$assigned = [int]$sku.consumedUnits
$unused = $purchased - $assigned
if ($unused -gt 0) {
[pscustomobject]@{
Sku = $sku.skuPartNumber
CapabilityStatus = $sku.capabilityStatus
Purchased = $purchased
Assigned = $assigned
Unused = $unused
}
}
}
$unusedLicences = @($unusedLicences | Where-Object { $_ } | Sort-Object -Property Unused -Descending)
# 2. Accounts blocked from signing in that still hold licences
$usersUri = 'https://graph.microsoft.com/v1.0/users?$select=id,displayName,userPrincipalName,accountEnabled,assignedLicenses&$top=999'
$blockedLicensedUsers = [System.Collections.Generic.List[object]]::new()
while ($usersUri) {
$page = Invoke-MgGraphRequest -Method GET -Uri $usersUri
foreach ($user in $page.value) {
if ($user.accountEnabled -eq $false -and $user.assignedLicenses.Count -gt 0) {
$licenceNames = foreach ($licence in $user.assignedLicenses) {
$skuNames[[string]$licence.skuId]
}
$blockedLicensedUsers.Add([pscustomobject]@{
DisplayName = $user.displayName
UserPrincipalName = $user.userPrincipalName
Licences = ($licenceNames | Where-Object { $_ }) -join ', '
})
}
}
$usersUri = $page.'@odata.nextLink'
}
# 3. Log a summary to Application Insights
$totalUnused = ($unusedLicences | Measure-Object -Property Unused -Sum).Sum
if (-not $totalUnused) { $totalUnused = 0 }
Write-Host "LICENCE_REPORT unusedSeats=$totalUnused skusWithUnusedSeats=$($unusedLicences.Count) blockedLicensedUsers=$($blockedLicensedUsers.Count)"
foreach ($item in $unusedLicences) {
Write-Host "LICENCE_REPORT_SKU $($item.Sku) purchased=$($item.Purchased) assigned=$($item.Assigned) unused=$($item.Unused)"
}
# 4. Optionally send the full report to a webhook (URL comes from Key Vault)
if ($env:REPORT_WEBHOOK_URL) {
if ($env:REPORT_WEBHOOK_URL.StartsWith('@Microsoft.KeyVault(')) {
throw 'REPORT_WEBHOOK_URL is an unresolved Key Vault reference. Check the vault role assignment and secret name.'
}
$report = [ordered]@{
functionApp = $env:WEBSITE_SITE_NAME
generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
totalUnusedSeats = $totalUnused
unusedLicences = $unusedLicences
blockedLicensedUsers = $blockedLicensedUsers
}
$body = $report | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post -Uri $env:REPORT_WEBHOOK_URL -Body $body -ContentType 'application/json' | Out-Null
Write-Host 'LICENCE_REPORT sent to webhook.'
}
}
catch {
Write-Error "UnusedLicenceReport failed: $($_.Exception.Message)" -ErrorAction Continue
throw
}
finally {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
The licence maths uses two properties returned by List subscribedSkus: prepaidUnits.enabled (seats you are paying for) minus consumedUnits (seats assigned). The user query asks for 999 users per page, the maximum page size, and follows @odata.nextLink until every page is read (List users). Calls go through Invoke-MgGraphRequest, so the only module you need is Microsoft.Graph.Authentication.
Use the optional IGNORED_SKUS app setting for free or trial products that always show thousands of spare seats. Set it to a comma-separated list of skuPartNumber values copied from your first report.
Step 7: Deploy and run it
From the M365Reports folder, deploy with Azure Functions Core Tools, or zip the project and deploy with the Azure CLI. The zip must contain the project root (the folder holding host.json), not a parent folder. Leave local.settings.json and editor folders such as .vscode out of the zip, because local.settings.json often holds local secrets. Core Tools leaves it out for you (Deploy to Flex Consumption).
# Option A: Azure Functions Core Tools
func azure functionapp publish $appName
# Option B: zip deployment with the Azure CLI (run in PowerShell 7)
# Leave local settings, source control and editor files out of the package
$excluded = @('local.settings.json', '.vscode', '.git', '.gitignore', '.funcignore')
$items = Get-ChildItem -Path . -Force | Where-Object { $_.Name -notin $excluded }
Compress-Archive -Path $items.FullName -DestinationPath ..\M365Reports.zip -Force
az functionapp deployment source config-zip --src ..\M365Reports.zip --name $appName --resource-group $rg
To test without waiting for the schedule, follow Microsoft’s guide to manually run a non HTTP-triggered function. Managed identity sign-in only works inside Azure, so you can’t run Connect-MgGraph -Identity or Connect-ExchangeOnline -ManagedIdentity on your own computer. Test the logic locally with an interactive sign-in, then test the identity in Azure.
Step 8 (optional): Add Exchange Online with the managed identity
Exchange Online PowerShell supports managed identity connections from Azure Functions, as well as Azure Automation runbooks, virtual machines and virtual machine scale sets (Connect-ExchangeOnline). We put Exchange jobs in their own function app so that each identity holds only the permissions its own jobs need. Repeat steps 1 and 2 for a second app (for example func-m365-exchange-contoso), plus step 4 if it will post to a webhook, then grant it Exchange access.
Grant Exchange.ManageAsApp and a read-only role
The identity needs the Exchange.ManageAsApp application permission on the Office 365 Exchange Online resource, plus a supported Microsoft Entra role. For a read-only report, use a read-only role from Microsoft’s supported list, such as Global Reader. Be aware that Global Reader can read everything a Global Administrator can, across the whole directory, so it’s broader than a forwarding report needs. Microsoft’s app-only guide also lets you add a service principal to a custom Exchange Online role group (for example one holding only the View-Only Recipients role), which is much narrower. Those steps are written for app registrations, so test them with your managed identity before relying on them. Assign Exchange Recipient Administrator or Exchange Administrator only to an identity whose job is to change things. The script below follows Microsoft’s procedure in Use Azure managed identities to connect to Exchange Online PowerShell. Run it as a Privileged Role Administrator, with -WhatIf first. It skips anything already assigned, so it’s safe to run again.
#Requires -Modules Microsoft.Graph.Applications, Microsoft.Graph.Identity.Governance
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string] $ManagedIdentityObjectId,
[string] $EntraRoleName = 'Global Reader'
)
$ErrorActionPreference = 'Stop'
# Process scope: these high-privilege scopes aren't cached for later PowerShell sessions
Connect-MgGraph -Scopes 'AppRoleAssignment.ReadWrite.All', 'Application.Read.All', 'RoleManagement.ReadWrite.Directory' -ContextScope Process -NoWelcome
try {
# Office 365 Exchange Online resource and the Exchange.ManageAsApp app role (same IDs in every tenant)
$exoSp = Get-MgServicePrincipal -Filter "AppId eq '00000002-0000-0ff1-ce00-000000000000'"
if (-not $exoSp) {
throw 'Office 365 Exchange Online service principal not found. See the Microsoft article for the fix.'
}
$manageAsAppRoleId = 'dc50a0fb-09a3-484d-be87-e023b12c6440'
# Look up the directory role first, so a typo in -EntraRoleName stops the script before any change
$roleDefinition = Get-MgRoleManagementDirectoryRoleDefinition -Filter "displayName eq '$EntraRoleName'"
if (-not $roleDefinition) {
throw "No Microsoft Entra role named '$EntraRoleName' was found. Check the spelling against the built-in roles list."
}
# 1. Exchange.ManageAsApp (skipped if already assigned)
$existingAppRole = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ManagedIdentityObjectId -All |
Where-Object { $_.AppRoleId -eq $manageAsAppRoleId -and $_.ResourceId -eq $exoSp.Id }
if ($existingAppRole) {
Write-Host 'Already granted: Exchange.ManageAsApp'
}
elseif ($PSCmdlet.ShouldProcess($ManagedIdentityObjectId, 'Grant Exchange.ManageAsApp')) {
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ManagedIdentityObjectId -PrincipalId $ManagedIdentityObjectId -AppRoleId $manageAsAppRoleId -ResourceId $exoSp.Id | Out-Null
Write-Host 'Granted: Exchange.ManageAsApp'
}
# 2. Directory role that sets what the identity can do in Exchange Online (skipped if already assigned)
$existingRole = Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '$ManagedIdentityObjectId' and roleDefinitionId eq '$($roleDefinition.Id)'"
if ($existingRole) {
Write-Host "Already assigned: $EntraRoleName"
}
elseif ($PSCmdlet.ShouldProcess($ManagedIdentityObjectId, "Assign Microsoft Entra role $EntraRoleName")) {
New-MgRoleManagementDirectoryRoleAssignment -PrincipalId $ManagedIdentityObjectId -RoleDefinitionId $roleDefinition.Id -DirectoryScopeId '/' | Out-Null
Write-Host "Assigned: $EntraRoleName"
}
}
finally {
Disconnect-MgGraph | Out-Null
}
Save the script as Grant-ExchangeAccessToManagedIdentity.ps1. Use the object (principal) ID of the Exchange app’s identity, which az functionapp identity assign printed when you repeated step 2 for the second app, not the licence report app’s ID:
# Preview first, then grant .\Grant-ExchangeAccessToManagedIdentity.ps1 -ManagedIdentityObjectId '<Exchange app identity object ID>' -WhatIf .\Grant-ExchangeAccessToManagedIdentity.ps1 -ManagedIdentityObjectId '<Exchange app identity object ID>'
Bundle ExchangeOnlineManagement (3.10.0 or later on PowerShell 7.6) into that app’s Modules folder with Save-PSResource, and add an app setting named EXO_ORGANIZATION holding your tenant’s primary .onmicrosoft.com domain, which is the value the -Organization parameter expects. Security & Compliance PowerShell (Connect-IPPSSession) isn’t available in PowerShell 7 on Linux, so keep compliance jobs elsewhere (Linux support for the module).
This example function reports mailboxes that forward mail, which is one of the first things attackers set up after compromising an account. Save it as MailboxForwardingReport/run.ps1, with a function.json like the one above. Mailbox addresses and forwarding destinations are personal information, so the function logs only the count to Application Insights and sends the detail to your webhook (the REPORT_WEBHOOK_URL Key Vault reference from step 4), where you control who can see it.
<#
.SYNOPSIS
Azure Functions timer trigger: reports mailboxes with forwarding configured.
.NOTES
Author: Elliot Munro - GCIT (https://gcit.com.au)
Identity: system-assigned managed identity with Exchange.ManageAsApp and a read-only role
App settings: EXO_ORGANIZATION = contoso.onmicrosoft.com
REPORT_WEBHOOK_URL (optional) = Key Vault reference to the URL that receives the details
#>
param($Timer)
$ErrorActionPreference = 'Stop'
if (-not $env:EXO_ORGANIZATION) {
throw 'The EXO_ORGANIZATION app setting is missing. Set it to the tenant''s primary .onmicrosoft.com domain.'
}
Import-Module ExchangeOnlineManagement
try {
Connect-ExchangeOnline -ManagedIdentity -Organization $env:EXO_ORGANIZATION -ShowBanner:$false
$mailboxes = Get-EXOMailbox -ResultSize Unlimited -Properties ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward |
Where-Object { $_.ForwardingSmtpAddress -or $_.ForwardingAddress }
$forwarding = @(foreach ($mailbox in $mailboxes) {
[pscustomobject]@{
UserPrincipalName = $mailbox.UserPrincipalName
RecipientTypeDetails = [string]$mailbox.RecipientTypeDetails
ForwardingSmtpAddress = [string]$mailbox.ForwardingSmtpAddress
ForwardingAddress = [string]$mailbox.ForwardingAddress
KeepsCopy = $mailbox.DeliverToMailboxAndForward
}
})
# Only the count goes to Application Insights. The details are personal information.
Write-Host "FORWARDING_REPORT mailboxesWithForwarding=$($forwarding.Count)"
if ($env:REPORT_WEBHOOK_URL -and $forwarding.Count -gt 0) {
if ($env:REPORT_WEBHOOK_URL.StartsWith('@Microsoft.KeyVault(')) {
throw 'REPORT_WEBHOOK_URL is an unresolved Key Vault reference. Check the vault role assignment and secret name.'
}
$report = [ordered]@{
functionApp = $env:WEBSITE_SITE_NAME
generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
mailboxesWithForwarding = $forwarding
}
$body = $report | ConvertTo-Json -Depth 4
Invoke-RestMethod -Method Post -Uri $env:REPORT_WEBHOOK_URL -Body $body -ContentType 'application/json' | Out-Null
Write-Host 'FORWARDING_REPORT sent to webhook.'
}
}
catch {
Write-Error "MailboxForwardingReport failed: $($_.Exception.Message)" -ErrorAction Continue
throw
}
finally {
Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
}
Get-EXOMailbox returns a minimum property set by default. The three forwarding properties belong to its Delivery property set, which is why they are requested by name (Get-EXOMailbox property sets).
Logging and monitoring with Application Insights
In a PowerShell function, Write-Host, Write-Output and Write-Information log at Information level, Write-Warning at Warning and Write-Error at Error (PowerShell developer reference: logging). Because the scripts prefix their summary lines, they are easy to find. Open the Application Insights resource, select Logs, and run:
traces | where timestamp > ago(7d) | where cloud_RoleName == "func-m365-reports-contoso" | where message startswith "LICENCE_REPORT" | project timestamp, message | order by timestamp desc
And to see failures:
exceptions | where timestamp > ago(7d) | where cloud_RoleName == "func-m365-reports-contoso" | project timestamp, type, outerMessage | order by timestamp desc
A failed timer run is not retried. It simply waits for the next scheduled time (Timer trigger retry behaviour). Create an Azure Monitor alert on failed executions so a broken report doesn’t go unnoticed for a week. For extra hardening, Application Insights can require Microsoft Entra authentication from the function app’s managed identity through the APPLICATIONINSIGHTS_AUTHENTICATION_STRING setting (Configure monitoring for Azure Functions).
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
| Graph calls return 403 or Authorization_RequestDenied soon after you granted permissions | Managed identity tokens are cached for up to around 24 hours. Confirm the assignment with Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId <object ID>, then wait. There is no supported way to force a refresh. |
| The term ‘Connect-MgGraph’ is not recognized | The Modules folder didn’t deploy. Check that it sits at the project root next to host.json, that the zip contains the project root, and that a .gitignore or .funcignore rule isn’t excluding module files. |
The app won’t start and logs gRPC System.TimeoutException entries |
Flex Consumption allows 30 seconds for app initialisation. Remove heavy work from profile.ps1 (such as importing large modules or signing in) and do it inside the function. |
Errors about managed dependencies or requirements.psd1 |
Managed dependencies aren’t supported on Flex Consumption. Delete requirements.psd1, remove managedDependency from host.json and bundle the modules. |
| The script reports an unresolved Key Vault reference | If a reference can’t be resolved, the app receives the literal @Microsoft.KeyVault(...) string. Check the Key Vault Secrets User assignment, the secret name and any vault firewall rules. The function app’s Diagnose and solve problems page includes a Key Vault Application Settings Diagnostics detector. |
| The report runs at the wrong local time | Schedules are UTC, and WEBSITE_TIME_ZONE isn’t supported on Flex Consumption. Adjust the NCRONTAB expression. |
Connect-ExchangeOnline -ManagedIdentity fails |
Check that Exchange.ManageAsApp and a supported Entra role are both assigned, that EXO_ORGANIZATION is the .onmicrosoft.com domain, and that the bundled module version matches the PowerShell version (3.10.0 or later needs 7.6). |
Sources: managed identity limitations, Flex Consumption considerations, Key Vault references troubleshooting and Exchange Online managed identity setup.
Cost notes
- Flex Consumption bills for execution, not idle time. In on demand mode you pay for the memory provisioned while functions are actually executing (in GB-seconds) and for the number of executions, each minus a monthly free grant. Each execution is billed for at least 1,000 ms, then rounded up to the nearest 100 ms. A report that runs once each weekday for a minute or two is usually covered by the free grant, but check current rates and grant amounts on the Azure Functions pricing page.
- Don’t add always ready instances to a scheduled report. Always ready instances are billed continuously and have no free grant. They reduce cold starts for busy HTTP APIs, which a daily report doesn’t need (Flex Consumption billing).
- Instance size. Microsoft recommends the 2,048 MB instance size as the default for most workloads. You can change it at any time with
az functionapp scale config set --instance-memory. - Application Insights is usually the biggest line item. The default configuration can produce high volumes of data, and you pay for what is ingested. Keep logging to summaries, as the scripts above do, and use log levels and sampling if an app becomes chatty (Configure monitoring for Azure Functions).
- Storage and Key Vault add small usage-based charges. Key Vault references are cached and refetched every 24 hours, so a daily report generates very few vault operations.
- Compared with the old design, there is no always-on App Service plan to pay for, which was the largest fixed cost in the original guide.
Important notes
- Anyone who can deploy code to the function app can use its identity. Microsoft points out that a user who can run code on a resource effectively holds every permission granted to that resource’s managed identity (Managed identity best practice recommendations). Restrict Contributor access to the resource group, use Privileged Identity Management for the people who deploy, and keep each app’s permissions to what its own functions use.
- Read-only first. Both examples only read data. Before adding write permissions such as
User.ReadWrite.Allor an Exchange admin role, consider a separate function app so the higher privilege is isolated, and log every change the function makes. - Review before you remove licences. A blocked account holding a licence is often a leaver who was missed, but some are deliberate, for example a shared mailbox that needs a licence for its size or archive. Check each one before reclaiming the seat.
- Managed identities work only in their own tenant. They don’t support cross-directory scenarios (Managed identities for App Service and Azure Functions). For an MSP, the options are to deploy the function app into each customer’s own Azure subscription, or to use a multitenant app registration, consented in each customer tenant, that trusts a user-assigned managed identity as a federated credential. Microsoft documents that the second pattern supports access to resources in another tenant and removes the need for a client secret (Configure an application to trust a managed identity). Don’t recreate the old Admin Agents and stored-password approach.
- Use GDAP for people, not shared accounts. Engineers who need interactive access to customer tenants should use GDAP relationships with least-privilege roles and MFA. Since late 2023, Microsoft no longer grants DAP when a partner creates a new customer tenant (GDAP frequently asked questions). If you followed our older guides that created reporting or Exchange admin accounts in every customer tenant, find and remove those accounts.
- No IP allow-listing to bypass MFA. Some of our older articles suggested adding Azure Functions IP addresses to MFA trusted IPs so password-based scripts would keep working. With a managed identity there is no user sign-in, so remove any such exclusions.
- Plan the next runtime change. PowerShell versions on Azure Functions have fixed end of support dates. Put the date for your version in your calendar and test the next version early.
Related resources
Microsoft Learn
- PowerShell developer reference for Azure Functions
- Azure Functions Flex Consumption plan hosting
- Create and manage function apps in the Flex Consumption plan
- Azure Functions runtime versions overview
- Managed identities for App Service and Azure Functions
- Assign a managed identity to an application role using PowerShell
- Use Azure managed identities to connect to Exchange Online PowerShell
- Use Key Vault references as app settings
- Timer trigger for Azure Functions
- Configure monitoring for Azure Functions
- Upgrade from Azure AD PowerShell to Microsoft Graph PowerShell
Related GCIT articles
- Export customers’ Microsoft Secure Scores to CSV and HTML reports (Microsoft Graph and GDAP across customer tenants)
- How to detect a breach in Microsoft 365
- GDAP Secure Application Model: unattended MSP automation with Microsoft Graph (customer tenants)
- GCIT GDAP partner toolkit: unattended automation guide (Azure Functions and Azure Automation across GDAP customer tenants)
Want your Microsoft 365 automation reviewed or rebuilt?
GCIT designs and runs secure Microsoft 365 automation for Australian businesses, with no stored admin passwords and least-privilege access by default. If you are still running scripts from the old Azure Functions era, get in touch with our team and we will help you retire them safely.