prod@blog:~$

Tracking Microsoft Authenticator Registrations Across Your Tenant Using PowerShell and Graph API


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 events
  • Directory.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:

  1. Category: UserManagement
  2. Activity: Update user
  3. Initiated by: Azure MFA StrongAuthenticationService
  4. 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.