Using KQL to Identify Different Types of MFA Events

Multifactor authentication (MFA) is an important security control in every environment. It is important to know what is happening and identify who is using MFA to protect their connections. It’s possible to do this using PowerShell, which is great for a one-time audit. But Sentinel is a better option if you need to continuously monitor the use of multifactor authentication or want to create visual reports using Workbooks. This article discusses how to create some basic queries to track MFA usage.

Getting the Data

MFA events are available using the Entra ID data connector for Microsoft Sentinel. To activate ingestion of events from Entra ID to Sentinel, navigate to Microsoft Sentinel > Data Connectors  >  Entra ID. At a minimum, activate the sign-in and audit logs. The audit logs contain administrative changes like Conditional Access policy changes or when a user registers MFA info. The sign-in logs identify the connections that use multifactor authentication.

MFA Usage

If you run simple queries interactively, it’s useful to visualize the results in a chart. A chart allows easy interpretation of data. To create a chart, use the ‘render’ operator after first ‘summarizing’ the data. Summarization means building the columns for the graph. In the example below, I chose to count the number of sign-ins per day, for each authentication type (single or multifactor). Figure 1 shows the result. An explanation of the colors is on the bottom left.

SigninLogs
| where TimeGenerated > ago(14d)
| summarize count() by AuthenticationRequirement, bin(TimeGenerated, 1d)
| render  areachart
Practical Sentinel: Auditing Multifactor Authentication with Sentinel
Figure 1: Visualizing authentication requirements

We can do the same to discover the multifactor authentication method in use (Figure 2). Before proceeding, we must understand how the logs are built. One sign-in can create multiple entries in the sign-in logs table. Each sign-in is uniquely identified by a CorrelationId. By using this identifier, we can retrieve the last entry and ensure we have only one entry for a sign-in. The sign-in logs contain a column called ‘AuthenticationDetails.’ This column contains an array of objects, each representing the authentication method used for the connection, such as a password or a secondary factor such as a phone, mobile app, etc.

SigninLogs
| where TimeGenerated > ago(14d)
| mv-expand todynamic(AuthenticationDetails)
| where  AuthenticationDetails.succeeded == true
| summarize count() by tostring(AuthenticationDetails.authenticationMethod), bin(TimeGenerated, 1d)
| render  areachart
Practical Sentinel: Auditing Multifactor Authentication with Sentinel
Figure 2: Visualizing the authentication requirement

Legacy MFA Usage

Entra ID currently supports three methods to enforce multifactor authentication:

The last option is considered legacy and is not recommended. If you combine this form of multifactor authentication with another (like security defaults or conditional access), the user experience can be inconsistent. I have seen weird issues where a user was unable to login because of a combination of Conditional Access and per-user MFA configuration.

I would advise you to stop using per-user MFA as it lacks conditions, doesn’t receive any updates, and creates an action to activate it for every new user. The query shown below helps to discover the accounts still using legacy MFA. It’s a simple query that uses the Entra ID Audit Log table and searches for the operation ‘Enable Strong Authentication’.

AuditLogs
 | where OperationName =~ "Enable Strong Authentication"
 | extend EnabledBy = parse_json(tostring(InitiatedBy.user))
| extend Targetprop = todynamic(TargetResources)
 | extend EnabledFor = tostring(Targetprop[0].userPrincipalName)

Identifying MFA disablement

When using legacy MFA, it is easy to identify if a threat actor has disabled MFA by running a query as seen below.

AuditLogs
 | where OperationName =~ "Disable Strong Authentication"

The same can be said when using Security Defaults. When security defaults is disabled, you can identify it by running the following KQL query.

AuditLogs
| where TargetResources[0].displayName == "Default Policy"

The situation is more complex if you use Conditional Access. To identify if a conditional access policy excludes a user from the requirement to use multifactor authentication, you need to think about the following scenarios:

  • A Conditional Access policy was removed.
  • The policy was disabled or set to ‘report-only’.
  • The user was excluded from the policy, either directly or through an exclusion group.
  • Conditions (such as platform…) were updated excluding the user’s behavior from the policy.
  • The action step was updated, for example from ‘require multifactor authentication’ to ‘require compliant device’.

Having a single, simple query that identifies if a user was excluded from MFA using Conditional Access is very difficult. That is why I typically depend on generic queries. The query below is developed from examples shared by a colleague and creates a detailed list of the changes made to a policy. With this information, you can investigate the changes and take appropriate action based on the actions.

AuditLogs
| where SourceSystem == "Azure AD"
| where OperationName in ("Add conditional access policy", "Delete conditional access policy", "Update conditional access policy")
| where Result == "success"
| extend Actor = InitiatedBy.user.userPrincipalName
| extend CAPolicy = TargetResources[0].displayName
| extend CAPolicySettings = TargetResources[0].modifiedProperties[0].newValue
// get the general settings of the NEW CA policy
| extend NewGeneralSettings = parse_json(tostring(parse_json(tostring(TargetResources[0].modifiedProperties))[0].newValue))
// get the general setting of the OLD CA policy
| extend OldGeneralSettings = parse_json(tostring(parse_json(tostring(TargetResources[0].modifiedProperties))[0].oldValue))
// get the new conditions
| extend NewConditions = parse_json(tostring(parse_json(tostring(parse_json(tostring(TargetResources[0].modifiedProperties))[0].newValue)).conditions))
// get the old conditions
| extend OldConditions = parse_json(tostring(parse_json(tostring(parse_json(tostring(TargetResources[0].modifiedProperties))[0].oldValue)).conditions))
//users
| extend ChangedUser = iff(tostring(OldConditions.users) != tostring(NewConditions.users), "Users, ", "")
// applications
| extend ChangedApplications = iff(tostring(OldConditions.applications) != tostring(NewConditions.applications), "Applications, ", "")
| order by TimeGenerated
// clientAppTypes
| extend ChangedClientAppTypes= iff(tostring(OldConditions.clientAppTypes) != tostring(NewConditions.clientAppTypes), "ClientAppTypes, ", "")
//locations
| extend ChangedLocations= iff(tostring(OldConditions.locations) != tostring(NewConditions.locations), "Locations, ", "")
| order by TimeGenerated
//platforms
| extend ChangedPlatforms= iff(tostring(OldConditions.platforms) != tostring(NewConditions.platforms), "Platforms, ", "")
// Devices
| extend ChangedDevices= iff(tostring(OldConditions.devices) != tostring(NewConditions.devices), "Devices, ", "")
//servicePrincipalRiskLevels
| extend ChangedServicePrincipalRiskLevels= iff(tostring(OldConditions.servicePrincipalRiskLevels) != tostring(NewConditions.servicePrincipalRiskLevels), "ServicePrincipalRiskLevels, ", "")
// SignInRiskLevels
| extend ChangedSignInRiskLevels= iff(tostring(OldConditions.signInRiskLevels) != tostring(NewConditions.signInRiskLevels), "SignInRiskLevels, ", "")
//userRiskLevels
| extend ChangedUserRiskLevels= iff(tostring(OldConditions.userRiskLevels) != tostring(NewConditions.userRiskLevels), "UserRiskLevels, ", "")
// grantcontrols
| extend ChangedGrantControl = iff(tostring(OldGeneralSettings.grantControls) != tostring(NewGeneralSettings.grantControls), "GrantControl, ", "")
// state (policy enabled or not)
| extend ChangedState = iff(tostring(OldGeneralSettings.state) != tostring(NewGeneralSettings.state), "State, ", "")
// sessionControl
| extend ChangedSessionControl = iff(tostring(OldGeneralSettings.sessionControls) != tostring(NewGeneralSettings.sessionControls), "SessionControl, ", "")
// show CA policy changes that were True
| extend Text = strcat(Actor, " has ", OperationName, " called ", CAPolicy, ". The following changes were done: ", ChangedUser, ChangedApplications, ChangedClientAppTypes, ChangedLocations, ChangedPlatforms, ChangedServicePrincipalRiskLevels, ChangedSignInRiskLevels, ChangedUserRiskLevels, ChangedGrantControl, ChangedState, ChangedSessionControl, ChangedDevices)
| extend Text2 = trim_end(', ', Text)
| project-reorder Text2, CAPolicy, Actor

This query outputs some easily readable text as seen in Figure 3:

Practical Sentinel: Auditing Multifactor Authentication with Sentinel
Figure 3: Sample output

MFA Registrations

Knowing who registers new authentication methods can be valuable. They can be used to follow up on the roll-out of the policies and identify user adoption. This query identifies new registrations.

AuditLogs
  | where Category =~ "UserManagement"
  | where ActivityDisplayName == “User registered security info”

A more extensive query is available on Microsoft Sentinel’s GitHub page. It focuses on identifying multifactor authentication changes for VIP users. What is a VIP user differs per organization, typically this is upper management. To me, this is a great way of working, as alerting for every MFA change is likely too noisy (depending on the size of your organization). By focusing on changes to VIP users, you can be alerted for changes on key accounts.

Passkeys

Passkeys are a new form of authentication available within Entra ID. They are tied to the Authenticator app on a smartphone and can be compared to a ‘virtualized FIDO2’ key. To learn more about passkeys, read this article.

To identify which users register passkeys, use this query:

AuditLogs
| where OperationName endswith "Add Passkey (device-bound)"

To find out more about the usage of passkeys, use the query from the first paragraph, modifying it slightly to search for the correct value of the ‘AuthenticationMethod’ property.

SigninLogs
| where TimeGenerated > ago(14d)
| mv-expand todynamic(AuthenticationDetails)
| where AuthenticationDetails.authenticationMethod == "Passkey (device-bound)"

KQL for the Win

KQL is a versatile tool and when used in combination with Microsoft Sentinel, it can provide you with valuable insights into your environment. By using the queries mentioned in this article, you can identify changes in your environment affecting multifactor authentication.

About the Author

Thijs Lecomte

Thijs is a security consultant out of Belgium, working at The Collective, an MSSP with a Microsoft-focused Security Operations Center. His work consists out of leading the SOC team and implementing Microsoft Security solutions (such as Microsoft Sentinel and Defender) as a consultant. He is an MVP in the Security category and is a regular speaker at events and user groups. His best-known publication is as co-author of the 'Microsoft 365 Security for the IT Pro' ebook.

Leave a Reply