I recently needed to identify which users in our tenant had registered Microsoft Authenticator as their MFA method, specifically to track the adoption of stronger authentication methods over SMS-based MFA. What started as a simple 30-day audit requirement evolved into a comprehensive PowerShell solution that could track new authenticator registrations across various time periods.
The Challenge
The security team wanted visibility into:
- Which users had recently registered Microsoft Authenticator
- When these registrations occurred
- What devices were being used
- The rate of adoption across the organization
The challenge was that the Azure AD audit logs contain numerous "Update user" events related to authentication methods, including token refreshes, re-authentications, and actual new registrations. I needed to filter out the noise and identify only genuinely NEW authenticator app registrations.
Setting Up the App Registration
First, I created an App Registration in Azure AD to authenticate against the Microsoft Graph API.
Required Permissions
The App Registration needs the following Application permissions (not delegated):
AuditLog.Read.All- To read the audit log eventsDirectory.Read.All- To read user directory information
Important: These must be Application permissions and require admin consent. After adding the permissions in the Azure portal, an admin needs to click "Grant admin consent for [your tenant]".
Creating the App Registration
# Using Azure CLI (if preferred)
az ad app create --display-name "MFA-Audit-Reporter" --sign-in-audience AzureADMyOrg
# Create a client secret
az ad app credential reset --id <app-id> --years 1
Make note of:
- Tenant ID:
your-tenant-id - Application (Client) ID:
your-app-id - Client Secret Value:
your-secret-value
The Initial Script - 30 Day Audit
I started with a basic script to query the last 30 days of audit logs, looking for StrongAuthenticationPhoneAppDetail modifications:
# Function to get access token
function Get-GraphAccessToken {
param(
[string]$TenantId,
[string]$AppId,
[string]$ClientSecret
)
$tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
# Build the request body as a URL-encoded string
$body = "client_id=$AppId&scope=https://graph.microsoft.com/.default&client_secret=$ClientSecret&grant_type=client_credentials"
try {
$response = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body $body -ContentType "application/x-www-form-urlencoded"
return $response.access_token
}
catch {
Write-Error "Failed to obtain access token: $_"
exit 1
}
}
Identifying the Right Events
The audit logs contain events that look like this when exported to CSV:
2026-01-15T13:33:00.1919304+00:00,Update user,Success,Azure MFA StrongAuthenticationService,
StrongAuthenticationPhoneAppDetail,[{"DeviceName":"iPhone 17 Pro",...}]
I needed to filter for:
- Category:
UserManagement - Activity:
Update user - Initiated by:
Azure MFA StrongAuthenticationService - Modified property:
StrongAuthenticationPhoneAppDetail
# Build the filter query
$filter = "activityDateTime ge $startDate and " +
"category eq 'UserManagement' and " +
"activityDisplayName eq 'Update user' and " +
"result eq 'success'"
$url = "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=$filter&`$top=999"
The Problem: Multiple Events Per User
Initially, I was getting multiple events per user - sometimes dozens per day. After investigating, I discovered that each audit event contains ALL registered devices for that user, not just new ones. Users with multiple devices (iPhone, Android tablet, work phone) would show all devices in every update event.
Here's what I was seeing for a user at croucher.cloud:
2025-12-31T12:22:11Z,john.smith@croucher.cloud,Update user,iPhone 17 Pro
2025-12-31T12:22:11Z,john.smith@croucher.cloud,Update user,iPhone 15 Pro
2025-12-31T12:22:11Z,john.smith@croucher.cloud,Update user,iPHone 16e
These were all from the SAME audit event, showing the user's complete list of registered devices.
The Solution: Detecting Only NEW Registrations
I rewrote the script to compare the oldValue and newValue properties in each audit event to identify when a device was actually added:
# Parse old and new values to detect additions
$oldDevices = @()
$newDevices = @()
if ($modifiedProperty.oldValue -and $modifiedProperty.oldValue -ne "[]") {
try {
$oldDevices = $modifiedProperty.oldValue | ConvertFrom-Json
} catch {}
}
if ($modifiedProperty.newValue -and $modifiedProperty.newValue -ne "[]") {
try {
$newDevices = $modifiedProperty.newValue | ConvertFrom-Json
} catch {}
}
# Identify newly added devices
foreach ($newDevice in $newDevices) {
$isNew = $true
# Check if this device existed in old value
foreach ($oldDevice in $oldDevices) {
if ($oldDevice.DeviceToken -eq $newDevice.DeviceToken) {
$isNew = $false
break
}
}
# This is a genuinely new registration
if ($isNew) {
# Process the new registration
}
}
Adding Flexible Time Periods
The script evolved to support multiple time periods through a parameter:
param(
[Parameter(Mandatory=$false)]
[ValidateSet("1hour", "24hours", "2days", "5days", "7days", "10days", "15days", "30days")]
[string]$TimePeriod = "30days"
)
switch ($TimePeriod) {
"1hour" { $HoursBack = 1; $PeriodDescription = "1 hour" }
"24hours" { $HoursBack = 24; $PeriodDescription = "24 hours" }
"7days" { $DaysBack = 7; $PeriodDescription = "7 days" }
"30days" { $DaysBack = 30; $PeriodDescription = "30 days" }
# ... other periods
}
Running the Script
With everything in place, here's how I run the script for different scenarios:
# Check registrations in the last 7 days
.\Get-AuthenticatorRegistrations.ps1 -TimePeriod 7days
# Quick check for the last hour (useful after a training session)
.\Get-AuthenticatorRegistrations.ps1 -TimePeriod 1hour
# Monthly audit
.\Get-AuthenticatorRegistrations.ps1 -TimePeriod 30days
Sample Output
NEW Microsoft Authenticator Registration Audit Report
=====================================================
Time Period: 7 days
Searching for NEW registrations in the last 7 days
Authenticating to Microsoft Graph...
Querying audit logs for the last 7 days...
Found 145 total StrongAuthenticationPhoneAppDetail events
Analyzing events to identify NEW registrations...
Results exported to: .\MFA_Authenticator_Registrations_7days_20260115_143022.csv
Summary of NEW Registrations:
------------------------------
Total NEW registrations: 23
Unique users with NEW registrations: 23
Device types newly registered:
iPhone 15 Pro: 8
iPhone SE (2nd generation): 6
Pixel 10: 5
Pixel 7: 4
Daily breakdown of NEW registrations:
2026-01-09: 3 new registrations
2026-01-10: 5 new registrations
2026-01-11: 2 new registrations
2026-01-12: 7 new registrations
2026-01-13: 4 new registrations
2026-01-14: 2 new registrations
Most recent 10 NEW registrations:
DateTime UserPrincipalName DeviceName
-------- ----------------- ----------
2026-01-14T15:42:13.000Z sarah.jones@croucher.cloud iPhone 15 Pro
2026-01-14T09:23:41.000Z mike.wilson@croucher.cloud Pixel 10
2026-01-13T16:55:02.000Z emma.brown@croucher.cloud Pixel 7
Issues Encountered and Solutions
Issue 1: Authentication Errors
Initially, I used a hashtable for the token request body, which caused issues with PowerShell's Invoke-RestMethod. The solution was to build the body as a URL-encoded string:
# This caused issues
$body = @{
client_id = $AppId
scope = "https://graph.microsoft.com/.default"
# ...
}
# This works correctly
$body = "client_id=$AppId&scope=https://graph.microsoft.com/.default&client_secret=$ClientSecret&grant_type=client_credentials"
Issue 2: JSON Parsing Errors
The audit log returns device details as JSON strings within JSON. I had to parse these nested structures carefully:
try {
$newDevices = $modifiedProperty.newValue | ConvertFrom-Json
} catch {
Write-Warning "Could not parse device details: $_"
}
Issue 3: Date Formatting Error
When trying to format dates for the daily breakdown, I initially used ToString("yyyy-MM-dd") which threw an error. The fix was to let PowerShell use its default ToString() method:
# Instead of this
$day.Name.ToString("yyyy-MM-dd")
# Do this
$dateStr = $day.Name.ToString("yyyy-MM-dd")
Write-Host " ${dateStr}: $($day.Count) new registrations"
Conclusion
This script has become an essential tool for tracking our MFA adoption progress. By focusing on genuinely new registrations rather than all authentication events, we can accurately measure how many users are transitioning to stronger authentication methods.
The ability to run quick checks (1 hour after a training session) or comprehensive audits (30-day reports for management) makes it versatile enough for both operational and strategic use cases.
For organizations looking to improve their security posture by moving away from SMS-based MFA, having visibility into authenticator app adoption is crucial. This script provides that visibility with actionable data that can drive your MFA migrations.