Skip to main content

Audit MFA Methods Across Your Tenant via Graph API

The Problem
#

You can see which MFA methods users have registered. Entra ID tells you that. What it doesn’t tell you at a glance is which methods they’re actually using day-to-day. A user might have registered a FIDO2 key, a phone, and Microsoft Authenticator – but if they’re approving every sign-in with SMS, their registered FIDO2 key is security theater.

If you’re planning a transition to phishing-resistant MFA (and you should be), you need data on actual usage patterns. Which users are still relying on phone call or SMS? Which apps are they authenticating to? How many sign-ins per method? This is the data that drives policy decisions and CA (Conditional Access) rule design.

Solution Overview
#

Two data sources, cross-referenced. Graph gives you registration state – what users have set up. Log Analytics gives you usage reality – what they actually reach for when a sign-in demands MFA. The script joins them per-user and dumps the result into Excel. For each user: what did they register, what do they actually use, and how often?

Prerequisites
#

  • Microsoft Graph scopes: UserAuthenticationMethod.Read.All, AuditLog.Read.All (plus User.Read.All only when using -UserProperties)
  • Azure Log Analytics workspace with sign-in logs (requires at minimum Entra ID P1 and diagnostic settings configured to send SigninLogs to the workspace)
  • Azure RBAC: Log Analytics Reader on the workspace (for Invoke-AzOperationalInsightsQuery)
  • PowerShell modules: Microsoft.Graph.Authentication, Az.OperationalInsights, ImportExcel
  • A CSV input file with a UPN column (semicolon-delimited, UTF-8)

One thing that trips people up: you need two separate authenticated sessions. Connect-MgGraph for Graph, Connect-AzAccount for Log Analytics. The script handles both connections on startup, but if you’re in a multi-tenant environment, make sure both are pointing at the right tenant.

Implementation
#

Input and Data Collection
#

The script takes a CSV of target UPNs rather than querying all users. This is intentional – in a large tenant, you’re typically analyzing MFA usage for specific populations (admins, a department migrating to passwordless, users being onboarded to FIDO2).

# Core MFA report only
.\Get-MfaMethodUsedFromUpn.ps1 `
    -CsvPath "C:\Temp\Users.csv" `
    -WorkspaceId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
    -Days 60

# With optional Entra ID user properties
.\Get-MfaMethodUsedFromUpn.ps1 `
    -CsvPath "C:\Temp\Users.csv" `
    -WorkspaceId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
    -UserProperties companyName, extensionAttribute13, department

The script connects to both Graph and Azure interactively on startup. Optional parameters include -Days (default 60), -TenantId (for multi-tenant), -OutputPath (defaults to a timestamped file in $env:TEMP), and -UserProperties (adds Entra ID user columns to the report).

Registered Methods via Graph
#

The userRegistrationDetails endpoint provides a clean view of what each user has registered:

$uri = 'https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails'
$registrationDetails = [System.Collections.Generic.List[object]]::new()
do {
    $response = Invoke-MgGraphRequest -Method GET -Uri $uri -ErrorAction Stop
    if ($response.value) {
        foreach ($item in $response.value) { $registrationDetails.Add($item) }
    }
    $uri = $response.'@odata.nextLink'
} while ($uri)
$filteredRegistrations = @($registrationDetails |
    Where-Object { $_.userPrincipalName -in $csvUsers.UPN })

This gives you the methodsRegistered array per user – things like microsoftAuthenticatorPush, fido2, phoneAuthentication, softwareOneTimePasscode, etc. Useful for knowing capability, but not actual behavior.

Entra ID User Details (Optional)
#

By default, the script skips the Entra ID user details call entirely – no User.Read.All scope needed, no pulling all tenant users just to get two columns. If you want organizational context in the report, pass -UserProperties with the Graph property names you care about:

-UserProperties companyName, department, extensionAttribute13

The script builds the $select dynamically from what you ask for. Extension attributes (extensionAttribute1-extensionAttribute15) map to the onPremisesExtensionAttributes bag in Graph. Standard properties like companyName, department, or jobTitle are queried directly. Each requested property appears as a PascalCase column in the output (e.g., companyName becomes CompanyName).

Actual Usage via Log Analytics (KQL)
#

The real insight comes from the sign-in logs. The KQL query extracts both authentication steps from each sign-in event:

SigninLogs
| where TimeGenerated > ago(60d)
| extend AuthenticationMethod1 = tostring(
    parse_json(AuthenticationDetails)[0].authenticationMethod)
| extend AuthenticationMethodDetail1 = tostring(
    parse_json(AuthenticationDetails)[0].authenticationMethodDetail)
| extend AuthenticationMethodSucceeded1 = tostring(
    parse_json(AuthenticationDetails)[0].succeeded)
| extend AuthenticationMethod2 = tostring(
    parse_json(AuthenticationDetails)[1].authenticationMethod)
| extend AuthenticationMethodDetail2 = tostring(
    parse_json(AuthenticationDetails)[1].authenticationMethodDetail)
| extend AuthenticationMethodSucceeded2 = tostring(
    parse_json(AuthenticationDetails)[1].succeeded)
| where AuthenticationMethod2 != ''
| where not(AuthenticationMethod1 == 'Previously satisfied'
    and AuthenticationMethod2 == 'Previously satisfied')
| where (AuthenticationMethodSucceeded1 == 'true'
    and AuthenticationMethodSucceeded2 == 'true')

A few important design decisions in this query:

  • Both authentication steps are extracted. A typical MFA sign-in has Step 1 (password or primary) and Step 2 (MFA method). We need the second step to know which MFA method was used.
  • “Previously satisfied” pairs are excluded. When both steps show “Previously satisfied,” the user had a cached session. This doesn’t tell us anything about MFA method choice.
  • Only successful authentications are included. Failed MFA attempts don’t represent user behavior – they represent typos, timeout, or attack attempts.
  • The time filter uses > ago(60d) – a rolling window from query execution time. In the script, the 60 is parameterized via -Days.

Method Usage Analysis
#

For each user, the script tallies MFA method usage (excluding passwords and “Previously satisfied” entries) and identifies the most-used method:

foreach ($log in $userSigninLogs) {
    foreach ($method in @($log.AuthenticationMethod1, $log.AuthenticationMethod2)) {
        if ($method -and $method -notin @('Previously satisfied', 'Password')) {
            $methodCount[$method] = ($methodCount[$method] ?? 0) + 1
        }
    }
}

$mostUsedMethod = if ($methodCount.Count -gt 0) {
    $top = $methodCount.GetEnumerator() |
        Sort-Object Value -Descending | Select-Object -First 1
    "$($top.Key) ($($top.Value))"
} else { '' }

The output per user includes:

  • UserPrincipalName
  • (Any columns from -UserProperties, if specified – e.g., CompanyName, Department, ExtensionAttribute13)
  • RegisteredMethods – what they’ve set up
  • MostUsedMethod – what they actually rely on (with count)
  • UsedMethods – all methods used with counts
  • Apps – which applications triggered MFA (with counts)
  • SignInCount – total MFA sign-in events

What You’ll Actually Find
#

When I run this, the same patterns come up every time: admins approving sign-ins by phone call while their FIDO2 key sits unused in a drawer, users who registered Authenticator two years ago and have touched nothing but SMS since, and apps that apparently never trigger MFA because someone’s CA policy has a gap nobody noticed. The zero-MFA-events users are usually the most interesting – either they’re excluded from a policy that should cover them, or they’re on legacy auth and bypassing modern authentication entirely.

Security Considerations
#

  • The Log Analytics query returns authentication method details including app names and UPNs. This is sensitive security telemetry.
  • Invoke-AzOperationalInsightsQuery requires appropriate RBAC on the Log Analytics workspace (at minimum, Log Analytics Reader).
  • The script uses Connect-MgGraph and Connect-AzAccount separately. Ensure both sessions are authenticated to the correct tenant, especially in multi-tenant environments.
  • The CSV-based user list approach limits blast radius – you’re not accidentally pulling MFA data for the entire tenant.

From Audit to Enforcement
#

Once you have this data, the enforcement path is a Conditional Access policy using an Authentication Strength that excludes weak methods. The output of this script maps directly to the user scope for that policy – you know exactly who is still on SMS or voice OTP, and you can plan targeted migration before flipping the switch.

If your tenant uses ADFS federation, PTA, or PHS where MFA is satisfied at the IdP level, those events won’t appear in Entra SigninLogs. This script will undercount MFA usage for those users. Factor that in before drawing conclusions.

When to Use This / When Not To
#

Use this before enforcing phishing-resistant MFA policies, during security posture assessments, or when planning a passwordless rollout. Also useful for identifying users who need targeted MFA method migration support.

Don’t use this for real-time MFA monitoring or alerting. For that, configure Sentinel analytics rules on sign-in logs directly. This script is a point-in-time analysis tool for planning and compliance.

GitHub
#

Get-MfaMethodUsedFromUpn.ps1