Most Entra ID security incidents start with a quiet membership change. Someone gets added to the Global Administrators role group, or to a Tier 0 access group, and nothing visible happens until the next audit. By then an attacker has had hours, or days, inside privileged scope. Polling the audit log every fifteen minutes does not close that gap, you need to know the second the change happens.
This is Part 1 of a two-part series on real-time Entra ID event handling with Azure Functions. Here we use Microsoft Graph change notifications to push group membership changes to a webhook the instant they occur. Part 2 will cover break-glass sign-in alerting via the diagnostic settings stream to Event Hub, since sign-in logs are not directly subscribable through Graph.
What we are building
The architecture is small and cheap to run. An Azure Function App with an HTTP trigger receives the change notifications. A Microsoft Graph subscription is registered against each privileged group, watching the members relationship. A timer-triggered function renews the subscription before it expires, and a Teams Incoming Webhook handles the actual alert.
Prerequisites
- An Entra ID tenant where you can register apps and create Function Apps
- A Function App on a publicly reachable HTTPS endpoint, Graph will not deliver to private endpoints
- Microsoft.Graph PowerShell module 2.x or newer
- A Teams channel with an Incoming Webhook URL, or any other notification target you prefer
Step 1: App registration and permissions
Create an app registration that the subscription will be owned by, and grant it the application permissions Group.Read.All and GroupMember.Read.All. Both need admin consent.
Connect-MgGraph -Scopes "Application.ReadWrite.All","AppRoleAssignment.ReadWrite.All"
$app = New-MgApplication -DisplayName "EntraGroupWatcher" -SignInAudience AzureADMyOrg
$sp = New-MgServicePrincipal -AppId $app.AppId
# Add a client secret valid for 12 months
$secret = Add-MgApplicationPassword -ApplicationId $app.Id `
-PasswordCredential @{ DisplayName = "fn-secret"; EndDateTime = (Get-Date).AddMonths(12) }
Write-Host "AppId: $($app.AppId)"
Write-Host "TenantId: $((Get-MgContext).TenantId)"
Write-Host "ClientSecret: $($secret.SecretText) # store this in Key Vault now"
Grant the two application permissions through the Enterprise applications blade in the Azure portal, or with New-MgServicePrincipalAppRoleAssignment. Either way a tenant admin needs to consent.
Step 2: The HTTP trigger and the validation handshake
Graph change notifications use a two-stage handshake. When you create a subscription, Graph immediately POSTs a request with ?validationToken= in the query string. Your function must echo the token back in plain text within ten seconds, or the subscription creation fails.
After that, every membership change produces a JSON payload with one or more value entries. Each entry carries the changed resource path, the change type, and a tenant ID. Crucially, group member change notifications do not include the user who was added or removed in the payload itself. You have to call Graph back to find the actual delta.
using namespace System.Net
param($Request, $TriggerMetadata)
# 1. Validation token handshake on subscription creation
if ($Request.Query.validationToken) {
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
ContentType = "text/plain"
Body = $Request.Query.validationToken
})
return
}
# 2. Validate clientState before trusting anything in the body
$expectedClientState = $env:GRAPH_CLIENT_STATE
foreach ($n in $Request.Body.value) {
if ($n.clientState -ne $expectedClientState) {
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::Unauthorized
})
return
}
}
# 3. Authenticate as the app and resolve the change
$cred = [PSCredential]::new(
$env:GRAPH_CLIENT_ID,
(ConvertTo-SecureString $env:GRAPH_CLIENT_SECRET -AsPlainText -Force)
)
Connect-MgGraph -ClientSecretCredential $cred -TenantId $env:GRAPH_TENANT_ID -NoWelcome
foreach ($n in $Request.Body.value) {
$groupId = ($n.resource -replace 'Groups/', '' -split '/')[0]
$group = Get-MgGroup -GroupId $groupId
$members = Get-MgGroupMember -GroupId $groupId -All |
Select-Object Id, @{ n = 'Upn'; e = { $_.AdditionalProperties.userPrincipalName } }
Send-TeamsAlert -GroupName $group.DisplayName -ChangeType $n.changeType -Members $members
}
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::Accepted
})
The Send-TeamsAlert helper is a thin POST to your Incoming Webhook URL. Keep it small, your function has to ack within thirty seconds or Graph treats the notification as failed and retries.
Step 3: Create the subscription
Group member change subscriptions live for at most 4230 minutes, just under three days. You create one per group, pointing at your Function endpoint.
$groups = @(
"11111111-2222-3333-4444-555555555555", # Global Administrators role-assignable group
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" # Tier 0 access group
)
$clientState = (New-Guid).Guid # store this in app settings as GRAPH_CLIENT_STATE
$funcUrl = "https://entra-watcher.azurewebsites.net/api/notifications?code=$env:FUNC_KEY"
foreach ($g in $groups) {
New-MgSubscription `
-ChangeType "updated" `
-NotificationUrl $funcUrl `
-Resource "groups/$g/members" `
-ExpirationDateTime (Get-Date).AddMinutes(4200).ToUniversalTime() `
-ClientState $clientState
}
Always validate clientState on the inbound notification, as shown in Step 2. It is the one secret you control end to end, and it confirms the payload came from your subscription rather than somebody spoofing your endpoint.
Step 4: Renew before it expires
A timer trigger running every 24 hours gives plenty of headroom. Patch each existing subscription, do not recreate them, recreation drops the internal sequence state and can cause missed events during the gap.
# Run-RenewSubscriptions.ps1, bound to a Timer trigger: "0 0 6 * * *"
param($Timer)
$cred = [PSCredential]::new(
$env:GRAPH_CLIENT_ID,
(ConvertTo-SecureString $env:GRAPH_CLIENT_SECRET -AsPlainText -Force)
)
Connect-MgGraph -ClientSecretCredential $cred -TenantId $env:GRAPH_TENANT_ID -NoWelcome
foreach ($s in (Get-MgSubscription)) {
Update-MgSubscription -SubscriptionId $s.Id `
-ExpirationDateTime (Get-Date).AddMinutes(4200).ToUniversalTime()
Write-Information "Renewed $($s.Resource) until $($s.ExpirationDateTime)"
}
Putting it all together
End to end the flow looks like this. An attacker, or an over-eager helpdesk script, adds a user to your Global Admins group. Within seconds Graph fires a notification at your Function. The Function validates clientState, calls Graph back to enumerate the current membership, diffs it against what it saw last time, and posts a Teams card listing the group, the change type, and who is now in the group. Total latency from change to Teams alert in my tenant runs about 6 to 10 seconds.
A few production notes worth calling out. Persist the last known membership snapshot in a Storage Table keyed by group ID, otherwise you cannot tell an “added” from a “removed” since Graph only tells you the relationship changed. If you want to drop the client secret entirely, use a User Assigned Managed Identity on the Function and a Federated Credential on the app registration. Whitelist the Graph notification IP ranges on your Function App, the list lives in the Microsoft Graph documentation and changes from time to time. Finally, log every received notification to Application Insights before processing, you will thank yourself the first time a notification storm hits a misbehaving group.
Continue to Part 2
Sign-in events, including break-glass account logins, are not exposed as a Graph change notification resource. In Part 2: Real-time Entra ID Break-glass Sign-in Alerts we wire the same Function App up to a second trigger, an Event Hub fed by the Entra ID diagnostic settings stream, and add a filter that pages on duty when a break-glass UPN signs in or when sign-in risk crosses a threshold. The two patterns share the same alert helpers and Key Vault references, so by the end of the series you have a single deployable that covers both privileged group changes and privileged account use.