Adversaries may be creating new user accounts to establish persistent access or exfiltrate data by triggering abnormal sign-in spikes. SOC teams should proactively hunt for this behavior in Azure Sentinel to identify potential account compromise or lateral movement tactics.
KQL Query
let starttime = 14d;
let timeframe = 1d;
let scorethreshold = 5;
let baselinethreshold = 25;
let aadFunc = (tableName:string){
// Succesful signins.
table(tableName)
| where TimeGenerated between (startofday(ago(starttime))..startofday(ago(timeframe)))
| where ResultType == 0
| extend timestamp = TimeGenerated, AccountCustomEntity = UserPrincipalName
};
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
let allSignins = union isfuzzy=true aadSignin, aadNonInt ;
let TimeSeriesData = union isfuzzy=true aadSignin, aadNonInt
| project TimeGenerated, UserPrincipalName
| make-series HourlyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step timeframe by UserPrincipalName
| project TimeGenerated, UserPrincipalName, HourlyCount;
let TimeSeriesAlerts = TimeSeriesData
| extend (anomalies, score, baseline) = series_decompose_anomalies(HourlyCount, scorethreshold, -1, 'linefit')
| mv-expand HourlyCount to typeof(double), TimeGenerated to typeof(datetime), anomalies to typeof(double),score to typeof(double), baseline to typeof(long)
| where anomalies > 0 | extend AnomalyHour = TimeGenerated
| where baseline > baselinethreshold // Filtering low count events per baselinethreshold
| project UserPrincipalName, AnomalyHour, TimeGenerated, HourlyCount, baseline, anomalies, score;
let AnomalyHours = TimeSeriesAlerts | where TimeGenerated > ago(2d) | project TimeGenerated;
// Filter the alerts for specified timeframe
TimeSeriesAlerts
| where TimeGenerated > ago(2d)
| join kind=inner (
union isfuzzy=true aadSignin, aadNonInt
| where TimeGenerated > ago(2d)
| extend DateHour = bin(TimeGenerated, 1h) // create a new column and round to hour
| where DateHour in ((AnomalyHours)) //filter the dataset to only selected anomaly hours
| summarize HourlyCount=count(), LatestAnomalyTime = arg_max(timestamp,*) by bin(TimeGenerated,1h), OperationName, Category, ResultType, ResultDescription, UserPrincipalName, UserDisplayName, AppDisplayName, ClientAppUsed, IPAddress, ResourceDisplayName
) on UserPrincipalName
| project LatestAnomalyTime, OperationName, Category, UserPrincipalName, UserDisplayName, ResultType, ResultDescription, AppDisplayName, ClientAppUsed, UserAgent, IPAddress, Location, AuthenticationRequirement, ConditionalAccessStatus, ResourceDisplayName, HourlyCount, baseline, anomalies, score
| extend timestamp = LatestAnomalyTime, IPCustomEntity = IPAddress, AccountCustomEntity = UserPrincipalName
id: 3c7fcea1-ec9f-4ea2-a555-156073b2d183
name: User Accounts - Successful Sign in Spikes
description: |
' Identifies measureable increase in successful sign-ins from user accounts.
Spike is determined based on Time series anomaly which will look at historical baseline values.
Ref : https://docs.microsoft.com/azure/active-directory/fundamentals/security-operations-user-accounts#monitoring-for-successful-unusual-sign-ins'
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- SigninLogs
- connectorId: AzureActiveDirectory
dataTypes:
- AADNonInteractiveUserSignInLogs
tactics:
- InitialAccess
relevantTechniques:
- T1078.004
tags:
- AADSecOpsGuide
query: |
let starttime = 14d;
let timeframe = 1d;
let scorethreshold = 5;
let baselinethreshold = 25;
let aadFunc = (tableName:string){
// Succesful signins.
table(tableName)
| where TimeGenerated between (startofday(ago(starttime))..startofday(ago(timeframe)))
| where ResultType == 0
| extend timestamp = TimeGenerated, AccountCustomEntity = UserPrincipalName
};
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
let allSignins = union isfuzzy=true aadSignin, aadNonInt ;
let TimeSeriesData = union isfuzzy=true aadSignin, aadNonInt
| project TimeGenerated, UserPrincipalName
| make-series HourlyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step timeframe by UserPrincipalName
| project TimeGenerated, UserPrincipalName, HourlyCount;
let TimeSeriesAlerts = TimeSeriesData
| extend (anomalies, score, baseline) = series_decompose_anomalies(HourlyCount, scorethreshold, -1, 'linefit')
| mv-expand HourlyCount to typeof(double), TimeGenerated to typeof(datetime), anomalies to typeof(double),score to typeof(double), baseline to typeof(long)
| where anomalies > 0 | extend AnomalyHour = TimeGenerated
| where baseline > baselinethreshold // Filtering low count events per baselinethreshold
| project UserPrincipalName, AnomalyHour, TimeGenerated, HourlyCount, baseline, anomalies, score;
let AnomalyHours = TimeSeriesAlerts | where TimeGenerated > ago(2d) | project TimeGenerated;
// Filter the alerts for specified timeframe
TimeSeriesAlerts
| where TimeGenerated > ago(2d)
| join kind=inner (
union isfuzzy=true aadSignin, aadNonInt
| where TimeGenerated > ago(2d)
| extend DateHour = bin(TimeGenerated, 1h) // create a new column and round to hour
| where DateHour in ((AnomalyHours)) //filter the dataset to only selected anomaly hours
| summarize HourlyCount=count(), LatestAnomalyTime = arg_max(timestamp,*) by bin(TimeGenerated,1h), OperationName, Category, ResultType, ResultDescription, UserPrincipalName, UserDisplayName, AppDisplayName, ClientAppUsed, IPAddress, ResourceDisplayName
) on UserPrincipalName
| project LatestAnomalyTime, OperationName, Category, UserPrincipalName, UserDisplayName, ResultType, R
| Sentinel Table | Notes |
|---|---|
AADNonInteractiveUserSignInLogs | Ensure this data connector is enabled |
SigninLogs | Ensure this data connector is enabled |
Scenario: Scheduled System Maintenance or Patching
Description: Automated maintenance tasks or patching jobs run during off-peak hours can cause a temporary spike in sign-in activity.
Filter/Exclusion: Exclude sign-ins that occur during scheduled maintenance windows using the Event ID 41 (for Windows) or Audit Logon events with Logon Type 10 (for remote administration). Use a filter like:
(EventID=41 OR EventID=4624 AND LogonType=10)
Scenario: User Account Synchronization with Azure AD
Description: Regular synchronization between on-premises Active Directory and Azure AD can result in a spike in sign-ins as user accounts are updated or synchronized.
Filter/Exclusion: Exclude sign-ins associated with synchronization services by checking the Logon Server field for known synchronization servers (e.g., AAD Sync Server or Azure AD Connect). Use a filter like:
LogonServer == "AAD Sync Server"
Scenario: Batch Job Execution with User Accounts
Description: Batch jobs that run under user accounts (e.g., Service Account) can cause a spike in sign-in events, especially if the job runs at a scheduled time.
Filter/Exclusion: Exclude sign-ins from service accounts by checking the User Name field for known service accounts (e.g., svc_batch_job). Use a filter like:
User == "svc_batch_job"
Scenario: User-Initiated Remote Access via RDP or VPN
Description: A legitimate increase in remote sign-ins during business hours (e.g., due to remote work) can trigger the rule.
Filter/Exclusion: Exclude sign-ins that