Detecting Suspicious Activity in Azure with KQL and Defender

Azure generates enormous amounts of log data — activity logs, Entra ID sign-in logs, resource diagnostic logs — but raw data is not detection. Detection requires knowing what patterns indicate an attack, writing queries that find those patterns, and setting up alerts that fire before damage is done. This page covers three specific KQL queries for common attack patterns and explains how Entra ID Identity Protection and Microsoft Defender for Cloud supplement query-based detection.

Prerequisites: what needs to be in Log Analytics

The queries on this page require that specific log types are being collected. Before the queries will return results, confirm:

  • For role escalation detection: Activity logs must be exported to Log Analytics. The table is AzureActivity. See Azure Activity Logs for the export setup.
  • For brute force detection: Entra ID Sign-in logs must be exported to Log Analytics. The table is SigninLogs. See log types in Azure for the Entra ID diagnostic settings.
  • For resource deletion spike detection: Activity logs must be exported to Log Analytics (AzureActivity table).
# Verify that AzureActivity data is flowing into your workspace
az monitor log-analytics query \
  --workspace my-workspace \
  --analytics-query "AzureActivity | summarize count() by bin(TimeGenerated, 1h) | order by TimeGenerated desc | take 24" \
  --output table

# Verify SigninLogs are flowing
az monitor log-analytics query \
  --workspace my-workspace \
  --analytics-query "SigninLogs | summarize count() by bin(TimeGenerated, 1h) | order by TimeGenerated desc | take 24" \
  --output table

KQL Query 1: Someone added themselves to the Owner role

Privilege escalation — a user or service principal granting themselves a highly privileged role — is one of the most impactful attack patterns in Azure. This query finds cases where a principal added themselves to the Owner role (or any specified high-privilege role) at subscription scope.

// Detect self-assignment of Owner role at subscription or high scope
AzureActivity
| where TimeGenerated > ago(7d)
| where OperationNameValue == "Microsoft.Authorization/roleAssignments/write"
| where ActivityStatusValue == "Success"
| extend RoleDefinitionId = tostring(parse_json(Properties).requestbody)
| extend ParsedProperties = parse_json(Properties)
| extend CallerObjectId = tostring(ParsedProperties.message)
// Parse the role assignment details from the request body
| extend RequestBody = parse_json(tostring(ParsedProperties.requestbody))
| extend PrincipalId = tostring(RequestBody.properties.principalId)
| extend RoleId = tostring(RequestBody.properties.roleDefinitionId)
// Owner role definition ID is constant across all Azure tenants
| where RoleId contains "8e3af657-a8ff-443c-a75c-2fe8c4bcb635"   // Owner
   or RoleId contains "b24988ac-6180-42a0-ab88-20f7382dd24c"     // Contributor
// Flag cases where the caller and the principal being assigned are the same
| extend IsSelfAssignment = (Caller == PrincipalId)
| project
    TimeGenerated,
    Caller,
    PrincipalId,
    IsSelfAssignment,
    RoleId,
    ResourceGroup,
    SubscriptionId,
    HTTPRequest = tostring(parse_json(HTTPRequest).clientIpAddress)
| order by TimeGenerated desc

What this query finds and why it matters:

  • Every successful role assignment of Owner or Contributor in the last 7 days.
  • The IsSelfAssignment column flags cases where the person making the assignment (Caller) is also the principal receiving it (PrincipalId). This is the most suspicious pattern — legitimate administrative work is usually done by one person for another.
  • Even non-self-assignments are worth reviewing: an attacker who has compromised Account A can use A to grant Owner to Account B (the attacker’s real account), which is not a self-assignment but is equally dangerous.
  • The IP address in HTTPRequest helps you determine if the action came from a known corporate network, a CI/CD pipeline IP range, or an unexpected location.

To convert this into a scheduled alert in Log Analytics:

// Alert version: trigger if any Owner assignment is made at subscription scope
// (not just self-assignment — all Owner grants at this scope warrant review)
AzureActivity
| where TimeGenerated > ago(5m)
| where OperationNameValue == "Microsoft.Authorization/roleAssignments/write"
| where ActivityStatusValue == "Success"
| extend RequestBody = parse_json(tostring(parse_json(Properties).requestbody))
| extend RoleId = tostring(RequestBody.properties.roleDefinitionId)
| where RoleId contains "8e3af657-a8ff-443c-a75c-2fe8c4bcb635"
| where ResourceId !contains "/resourceGroups/"  // Subscription-scope only
| project TimeGenerated, Caller, RoleId, SubscriptionId, ResourceId

Configure this as a Log Analytics alert rule with a 5-minute evaluation window and zero-tolerance threshold (any result triggers the alert).

KQL Query 2: Multiple failed login attempts from the same IP

Brute-force and password spray attacks appear as many failed authentication attempts from one or a few IP addresses, targeting multiple accounts. This query detects that pattern in Entra ID sign-in logs.

// Detect brute force: 10+ failed sign-in attempts from the same IP in 30 minutes
SigninLogs
| where TimeGenerated > ago(1h)
| where ResultType != "0"  // ResultType "0" means success; anything else is failure
// Filter to failure codes that indicate credential problems (not MFA, not blocked user)
| where ResultType in (
    "50126",   // Invalid username or password
    "50053",   // Account locked
    "50055",   // Password expired
    "50056",   // Invalid credentials
    "50076"    // MFA required (may indicate password was correct but MFA blocked)
)
| summarize
    FailedAttempts = count(),
    TargetedAccounts = dcount(UserPrincipalName),
    AccountList = make_set(UserPrincipalName, 10),
    FirstAttempt = min(TimeGenerated),
    LastAttempt = max(TimeGenerated)
    by IPAddress, bin(TimeGenerated, 30m)
| where FailedAttempts >= 10
| extend DurationMinutes = datetime_diff('minute', LastAttempt, FirstAttempt)
| project
    TimeWindow = TimeGenerated,
    IPAddress,
    FailedAttempts,
    TargetedAccounts,
    AccountList,
    DurationMinutes,
    FirstAttempt,
    LastAttempt
| order by FailedAttempts desc

What this query finds and why it matters:

  • Any IP address that produced 10 or more authentication failures in a 30-minute window.
  • TargetedAccounts distinguishes a brute force attack (many failures against one account) from a password spray attack (one or a few failures against many accounts). Password spray is the more dangerous pattern because it evades per-account lockout policies.
  • AccountList shows which accounts were targeted, helping you prioritize which accounts to review for potential compromise.
  • ResultType 50076 (MFA required) is included because if an attacker reaches the MFA prompt, they have already found a valid username and password — the only thing protecting the account is MFA. These accounts need immediate review.

A complementary query to check whether any attempt from a flagged IP succeeded:

// Check if the attacking IP had any successful logins (critical if yes)
let AttackingIPs = SigninLogs
| where TimeGenerated > ago(1h)
| where ResultType != "0"
| summarize count() by IPAddress
| where count_ >= 10
| project IPAddress;
SigninLogs
| where TimeGenerated > ago(2h)
| where ResultType == "0"  // Successful logins
| where IPAddress in (AttackingIPs)
| project TimeGenerated, UserPrincipalName, IPAddress, Location, AppDisplayName
| order by TimeGenerated desc

Any results from the second query require immediate incident response — an account may have been compromised.

KQL Query 3: Resource deletion spike

A ransomware or destructive attack against Azure infrastructure often involves deleting resource groups, storage accounts, databases, or VMs in rapid succession. This query detects an abnormal spike in deletion activity.

// Detect resource deletion spike: more than 5 deletions by one caller in 15 minutes
AzureActivity
| where TimeGenerated > ago(24h)
| where ActivityStatusValue == "Success"
// Match any delete operation — resource groups, VMs, storage, databases, etc.
| where OperationNameValue has "delete" or OperationNameValue has "Delete"
// Exclude expected low-level delete sub-operations (deployment slot cleans, etc.)
| where OperationNameValue !contains "Microsoft.Resources/deployments"
| summarize
    DeletionCount = count(),
    DeletedResources = make_set(ResourceId, 20),
    ResourceTypes = make_set(ResourceProviderValue, 10),
    FirstDeletion = min(TimeGenerated),
    LastDeletion = max(TimeGenerated)
    by Caller, bin(TimeGenerated, 15m)
| where DeletionCount >= 5
| extend DurationSeconds = datetime_diff('second', LastDeletion, FirstDeletion)
| extend DeletionsPerMinute = round(toreal(DeletionCount) / (DurationSeconds / 60.0), 1)
| project
    TimeWindow = TimeGenerated,
    Caller,
    DeletionCount,
    DeletionsPerMinute,
    ResourceTypes,
    DeletedResources,
    FirstDeletion,
    LastDeletion
| order by DeletionCount desc

What this query finds and why it matters:

  • Any principal that performed 5 or more deletion operations in a 15-minute window.
  • DeletionsPerMinute helps distinguish automated cleanup scripts (which run at a steady rate) from destructive attacks (which tend to run as fast as the API allows).
  • ResourceTypes shows whether the deletions are spreading across multiple resource types (Storage, Compute, Database all deleted in the same window) — cross-type deletions are more likely to be an attack than a targeted cleanup of one resource type.
  • DeletedResources gives you the list of affected resource IDs to start recovery assessment.

Adjust the threshold (5 deletions, 15 minutes) based on your environment. A DevOps team that regularly runs terraform destroy in development will trigger this query constantly — add a filter to exclude the CI/CD service principal or development subscription from the alert.

If you need to investigate further after this alert fires, remember that Azure keeps some deleted resources recoverable. Resource groups are deleted asynchronously — the activity log may show a delete succeeded before all sub-resources are removed. Storage accounts have soft-delete features. SQL databases have point-in-time restore. Assess recovery options immediately when an alert fires.

Entra ID Identity Protection

Entra ID Identity Protection (requires Entra ID P2 license) applies Microsoft’s threat intelligence to your sign-in and identity data continuously. Unlike the KQL queries above, which you write and maintain, Identity Protection runs automatically without configuration.

It detects and scores two types of risk:

  • Sign-in risk: Anomalies in a single authentication event — an impossible travel event (login from London, then five minutes later from New York), a login from a Tor exit node, a login using a known malicious IP address, an unfamiliar sign-in properties alert (new device, new location, new browser).
  • User risk: Accumulated signals indicating that a user account may be compromised — leaked credentials found in breach data dumps, repeated anomalous sign-ins, malware-linked IP addresses.

Identity Protection surfaces these risks in the Entra ID portal and, more importantly, integrates with Conditional Access policies. You can configure a Conditional Access policy that says: “If sign-in risk is high, require MFA or block access.” This provides automated risk-adaptive authentication without manual intervention for every alert.

# View risky sign-ins via CLI (requires appropriate permissions)
az rest \
  --method GET \
  --url "https://graph.microsoft.com/v1.0/identityProtection/riskyUsers" \
  --headers "Content-Type=application/json"

Identity Protection logs feed into the AADRiskyUsers and AADUserRiskEvents tables in Log Analytics when Entra ID logs are exported, allowing you to incorporate risk signals into your KQL queries.

Microsoft Defender for Cloud alerts

Microsoft Defender for Cloud analyzes Azure resource configuration and activity across your subscriptions and generates security alerts automatically. It covers both Azure-native services and workloads running on VMs and containers.

Defender for Cloud provides two layers:

  • Secure Score and recommendations: A continuous assessment of your resource configurations against the Microsoft Cloud Security Benchmark. Each finding is a recommendation (for example, “Enable MFA for accounts with Owner permissions” or “Storage accounts should restrict public access”). This is free and enabled by default via the Defender for Cloud Free tier.
  • Defender plans (paid): Real-time threat detection for specific resource types. Defender for Servers detects anomalous process execution and network behavior on VMs. Defender for Key Vault detects unusual access patterns. Defender for Storage detects unusual blob access and potential malware uploads. Each plan adds threat intelligence and behavioral analysis on top of the raw log data.
# View current Defender for Cloud alerts
az security alert list \
  --query "[].{AlertName:alertType, Severity:severity, ResourceID:compromisedEntity, Time:startTimeUtc, Status:status}" \
  --output table

# View Defender for Cloud recommendations
az security assessment list \
  --query "[?status.code=='Unhealthy'].{Name:displayName, Severity:metadata.severity}" \
  --output table

Defender alerts appear in the Defender for Cloud portal, in the Activity Log under the Security category, and in Log Analytics in the SecurityAlert table if your workspace is connected. KQL query to see recent high-severity alerts:

SecurityAlert
| where TimeGenerated > ago(7d)
| where AlertSeverity in ("High", "Critical")
| project TimeGenerated, AlertName, AlertSeverity, Description, Entities, RemediationSteps
| order by TimeGenerated desc

Setting up alert rules in Azure Monitor

KQL queries become actionable when wrapped in Azure Monitor alert rules that notify you when the query returns results.

# Create an alert rule for the Owner role assignment query
# First, get the Log Analytics workspace resource ID
WORKSPACE_ID=$(az monitor log-analytics workspace show \
  --workspace-name my-workspace \
  --resource-group my-rg \
  --query id \
  --output tsv)

# Create an action group for notifications
az monitor action-group create \
  --name "security-alerts-ag" \
  --resource-group my-rg \
  --short-name "secalert" \
  --action email security-team security-team@contoso.com

ACTION_GROUP_ID=$(az monitor action-group show \
  --name "security-alerts-ag" \
  --resource-group my-rg \
  --query id \
  --output tsv)

# Create the scheduled query alert rule
az monitor scheduled-query create \
  --name "owner-role-assignment-alert" \
  --resource-group my-rg \
  --scopes "$WORKSPACE_ID" \
  --condition-query "AzureActivity | where TimeGenerated > ago(5m) | where OperationNameValue == 'Microsoft.Authorization/roleAssignments/write' | where ActivityStatusValue == 'Success' | extend RoleId = tostring(parse_json(tostring(parse_json(Properties).requestbody)).properties.roleDefinitionId) | where RoleId contains '8e3af657-a8ff-443c-a75c-2fe8c4bcb635'" \
  --condition-threshold 0 \
  --condition-operator GreaterThan \
  --evaluation-frequency 5m \
  --window-size 5m \
  --severity 1 \
  --action "$ACTION_GROUP_ID" \
  --description "Fires when an Owner role is assigned. Requires immediate review."

Additional patterns worth monitoring

Beyond the three detailed KQL queries, these patterns in activity and sign-in logs are worth building alerts for:

  • Resource lock deletions: Filter activity logs on Microsoft.Authorization/locks/delete. A lock deleted immediately before a resource deletion is a strong indicator of deliberate, potentially unauthorized destruction.
  • New service principal credential additions: Filter on Microsoft.Authorization/applicationObjects/credentials/write. An attacker who compromises an application registration may add a new client secret to maintain persistent access.
  • Diagnostic settings disabled or deleted: Filter on operations against Microsoft.Insights/diagnosticSettings with a delete verb. An attacker covering their tracks may disable logging before taking destructive actions.
  • Policy assignment deletions: Filter on Microsoft.Authorization/policyAssignments/delete. Removing a deny policy on resource locations or required tags can enable subsequent policy-violating actions.
  • Logins from countries your organization does not operate in: Query SigninLogs for Location values in countries outside your expected set, especially for administrative or privileged accounts.

Common mistakes in suspicious activity detection

  1. Creating alerts before having a response plan. An alert that fires and gets ignored is worse than no alert — it trains your team to ignore alerts. Write a runbook for each alert before enabling it, so responders know exactly what to do when it fires.
  2. Setting thresholds too low in busy environments. An alert for any single deletion in a subscription that runs Terraform destroy regularly will produce hundreds of false positives per day. Calibrate thresholds based on your baseline activity, not generic guidance.
  3. Not testing KQL queries against historical data before deploying as alerts. Run the query against the last 30 days of data before setting up the alert. If it produces hundreds of results for known-good activity, tune it. If it produces zero results even though you know suspicious things happened, the query logic needs revision.
  4. Relying only on Defender for Cloud without any custom queries. Defender is excellent but covers known attack patterns. Custom KQL queries catch environment-specific anomalies — an unusual caller performing an unusual operation that does not match any generic threat signature but violates your organization’s specific patterns.

Frequently asked questions

Do I need Microsoft Sentinel to run these KQL queries?

No. All three KQL queries in this page run in a standard Log Analytics workspace. Microsoft Sentinel is built on top of Log Analytics and adds a SIEM layer (incident management, playbooks, threat intelligence, detection rules), but the underlying query language and data tables are the same. You can run these queries in any Log Analytics workspace where the relevant log types are being collected. Sentinel is recommended for organizations that want automatic threat detection and incident management, but it is not required to start writing KQL-based alerts.

How often should I run these queries as scheduled alerts?

Configure them as Azure Monitor alert rules or Sentinel analytics rules that run every 5-15 minutes with a lookback window matching the expected pattern (for example, 5 minutes of logins for a brute-force check, or 24 hours for owner escalation). Avoid running high-cardinality queries too frequently — a complex query over 30 days of data running every minute will incur significant Log Analytics query costs and may hit rate limits.

What should I do when an alert fires?

Have a documented runbook for each alert type before you enable it. For an owner role escalation alert: verify if the assignment was made by a known automation account or a human, check if there is a matching change request ticket, and if neither matches, immediately revoke the role assignment and investigate the account that made the change. For brute force attempts: block the source IP at the network layer, review whether any attempt succeeded, and reset credentials if needed. Alerts without documented responses lead to alert fatigue and eventually get ignored.

Last verified: 19 March 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.