Azure Key Vault: The High-Value Queries Your SOC Isn't Running

Azure Key Vault: The High-Value Queries Your SOC Isn't Running

All right class.

Key Vault is where your secrets live. Connection strings, API keys, certificates, encryption keys, service account credentials. If an attacker gets into your environment and makes it to Key Vault, the game has already shifted significantly in their favour. At that point they're not breaking in anymore, they're just shopping.

The problem is most environments have no detection on it whatsoever. Zero analytic rules. No hunting. No baseline. Just raw audit logs sitting in AzureDiagnostics that nobody ever looks at because initial templates from Microsoft were too noisy to be considered as a good detection source.

Hopefully you can go away with connecting Defender for Cloud to Key Vault, which will give you nice insights and alerting but it would also be nice to use the logs if you are already ingesting them right?

This post gives you eight production-ready KQL queries built and tested against real Key Vault audit logs. Each one catches a different attack pattern or insider risk signal. Will it cover every problem on the planet? Definitely not - but it will give you much better detection than what you currently may have.

Before You Run Anything

Key Vault logs land in AzureDiagnostics under ResourceType = "VAULTS". You need the diagnostic settings on each vault configured to send AuditEvent category logs to your Log Analytics workspace. Without that, none of this works.

Also worth noting: If you're running these in a fresh workspace, all eight queries here use only AzureDiagnosticsIdentityInfo, and BehaviorAnalytics , so usually they'll work out of the box for any base Sentinel deployment.

What Each Query Actually Catches

There are eight queries below. Each is production-ready, tested on live audit logs, and enriched with IdentityInfo or UEBA context where relevant to give your SOC the triage context they need without having to go digging.


KQL 1: First-Time Vault Access

let lookback     = 30d;
let detectWindow = 1d;
let AllowedAppIds = dynamic(["509e4652-da8d-478d-a730-e9d4a1996ca4","8cae6e77-e04e-42ce-b5cb-50d82bce26b1"]);
let OperationList = dynamic(["SecretGet","KeyGet","VaultGet","SecretList","KeyList","CertificateGet","CertificateList"]);
let Baseline =
    AzureDiagnostics
    | where TimeGenerated between (ago(lookback) .. ago(detectWindow))
    | where ResourceType =~ "VAULTS" and ResultType =~ "Success"
    | where OperationName in (OperationList)
    | where not(identity_claim_appid_g in (AllowedAppIds))
    | extend
        CallerUPN   = coalesce(column_ifexists("identity_claim_upn_s",""), column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s","")),
        CallerAppId = column_ifexists("identity_claim_appid_g","")
    | extend CallerKey = iff(isnotempty(CallerUPN), CallerUPN, CallerAppId)
    | summarize BaselineVaults = make_set(Resource) by CallerKey;
let Recent =
    AzureDiagnostics
    | where TimeGenerated > ago(detectWindow)
    | where ResourceType =~ "VAULTS" and ResultType =~ "Success"
    | where OperationName in (OperationList)
    | where not(identity_claim_appid_g in (AllowedAppIds))
    | extend
        CallerUPN       = coalesce(column_ifexists("identity_claim_upn_s",""), column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s","")),
        CallerObjectId  = coalesce(column_ifexists("identity_claim_oid_g",""), column_ifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g","")),
        CallerAppId     = column_ifexists("identity_claim_appid_g",""),
        requestUri_s    = column_ifexists("requestUri_s",""),
        clientInfo_s    = column_ifexists("clientInfo_s",""),
        CallerIPAddress = column_ifexists("CallerIPAddress","")
    | extend CallerKey = iff(isnotempty(CallerUPN), CallerUPN, CallerAppId)
    | summarize
        EventCount  = count(),
        Operations  = make_set(OperationName, 20),
        Vaults      = make_set(Resource, 20),
        IPAddresses = make_set(CallerIPAddress, 10),
        UserAgents  = make_set(clientInfo_s, 5),
        SecretURIs  = make_set(requestUri_s, 50),
        FirstSeen   = min(TimeGenerated),
        LastSeen    = max(TimeGenerated)
        by CallerKey, CallerObjectId;
Recent
| join kind=leftanti Baseline on CallerKey
| join kind=leftouter (
    IdentityInfo
    | where TimeGenerated > ago(14d)
    | summarize arg_max(TimeGenerated,*) by AccountObjectId
    | project AccountObjectId, AccountDisplayName, Department, JobTitle, RiskLevel
) on $left.CallerObjectId == $right.AccountObjectId
| project-reorder FirstSeen, LastSeen, CallerKey, AccountDisplayName, Department, JobTitle, RiskLevel, EventCount, Vaults, Operations, IPAddresses, SecretURIs

What it does: Builds a 30-day baseline of which identities accessed which vaults. Fires on anything that accessed a vault in the last 24 hours that had no history in the baseline window.

This is your widest net. It catches attackers who compromised an account and immediately pivoted to Key Vault, service principals that suddenly got access to something they shouldn't have, and developers who were handed vault permissions that nobody approved.

The enrichment join against IdentityInfo gives you DepartmentJobTitle, and RiskLevel on every hit. A High risk user from Finance appearing for the first time on a vault called PROD-CRYPTO-KEYS is a different conversation than a DevOps engineer appearing on their own team's vault.

What to look for:

  • Identities with no business context near Key Vault showing up in Vaults
  • Multiple vaults in a single Vaults set, especially production ones
  • Operations containing SecretGet on a first-ever appearance

KQL 2: Vault Config Change Followed by Secret Reads

let PolicyChangeWindow = 30m;
let LookbackDays       = 7d;
let ManagementOps = dynamic(["VaultPatch","VaultPut","VaultDelete"]);
let ReadOps       = dynamic(["SecretGet","SecretList","KeyGet","KeyList","KeyDecrypt","CertificateGet","CertificateList"]);
let VaultChanges =
    AzureDiagnostics
    | where TimeGenerated > ago(LookbackDays)
    | where ResourceType =~ "VAULTS"
    | where OperationName in (ManagementOps)
    | where ResultType =~ "Success"
    | extend Actor = tolower(coalesce(
        column_ifexists("identity_claim_upn_s",""),
        column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s","")))
    | extend ActorObjectId = coalesce(
        column_ifexists("identity_claim_oid_g",""),
        column_ifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g",""))
    | project ChangeTime = TimeGenerated, VaultName = toupper(Resource),
        ChangeType = OperationName, Actor, ActorObjectId, ActorIP = CallerIPAddress;
let DataPlaneReads =
    AzureDiagnostics
    | where TimeGenerated > ago(LookbackDays)
    | where ResourceType =~ "VAULTS"
    | where OperationName in (ReadOps)
    | where ResultType =~ "Success"
    | extend ReaderUPN = tolower(coalesce(
        column_ifexists("identity_claim_upn_s",""),
        column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s","")))
    | extend ReaderObjectId = coalesce(
        column_ifexists("identity_claim_oid_g",""),
        column_ifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g",""))
    | extend requestUri_s = column_ifexists("requestUri_s","")
    | project ReadTime = TimeGenerated, VaultName = toupper(Resource),
        OperationName, ReaderUPN, ReaderObjectId, ReaderIP = CallerIPAddress, requestUri_s;
VaultChanges
| join kind=inner DataPlaneReads on VaultName
| where ReadTime between (ChangeTime .. (ChangeTime + PolicyChangeWindow))
| where ActorObjectId == ReaderObjectId or isempty(ActorObjectId)
| extend TimeDeltaMinutes = datetime_diff('minute', ReadTime, ChangeTime)
| summarize
    TotalReads      = count(),
    UniqueSecrets   = dcount(requestUri_s),
    Operations      = make_set(OperationName),
    ReaderIPs       = make_set(ReaderIP, 10),
    Secrets         = make_set(requestUri_s, 30),
    MinTimeDeltaMin = min(TimeDeltaMinutes),
    ChangeTypes     = make_set(ChangeType)
    by ChangeTime, VaultName, Actor, ActorIP, ActorObjectId
| extend HighRisk = iff(MinTimeDeltaMin < 10 and UniqueSecrets > 3, true, false)
| project-reorder ChangeTime, VaultName, Actor, ActorIP, HighRisk, MinTimeDeltaMin,
    TotalReads, UniqueSecrets, ChangeTypes, Secrets, ReaderIPs, Operations
| sort by MinTimeDeltaMin asc

What it does: Looks for VaultPatch or VaultPut operations followed by data plane reads within 30 minutes from the same identity.

This is the classic privilege escalation pattern in RBAC environments. Someone modifies vault configuration, immediately reads secrets from it. Could be a tag update, firewall rule change, or any ARM-level write to the vault resource.

The query joins on ActorObjectId == ReaderObjectId so it only fires when the same identity that made the change also performed the subsequent reads. It doesn't fire on coincidental activity from different people on the same vault.

What to look for:

  • MinTimeDeltaMin modified + read the secret within a short timeframe, something we would like to know about
  • HighRisk = true fires when that delta is under 10 minutes AND more than 3 unique secrets were accessed
  • ChangeType = VaultPut is worth paying attention to; it's a full vault write rather than a patch

KQL 3: Cross-Vault Secret Enumeration

let lookback          = 1d;
let MinVaultThreshold = 3;
let MinSecretThreshold = 5;
let AllowedAppIds = dynamic(["509e4652-da8d-478d-a730-e9d4a1996ca4","8cae6e77-e04e-42ce-b5cb-50d82bce26b1"]);
AzureDiagnostics
| where TimeGenerated > ago(lookback)
| where ResourceType =~ "VAULTS" and ResultType =~ "Success"
| where OperationName in ("SecretGet","KeyGet","KeyDecrypt","SecretList","KeyList","CertificateGet","CertificateList")
| where not(identity_claim_appid_g in (AllowedAppIds))
| extend
    CallerUPN       = coalesce(column_ifexists("identity_claim_upn_s",""), column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s","")),
    CallerObjectId  = coalesce(column_ifexists("identity_claim_oid_g",""), column_ifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g","")),
    CallerAppId     = column_ifexists("identity_claim_appid_g",""),
    requestUri_s    = column_ifexists("requestUri_s",""),
    clientInfo_s    = column_ifexists("clientInfo_s",""),
    CallerIPAddress = column_ifexists("CallerIPAddress","")
| extend CallerKey = iff(isnotempty(CallerUPN), CallerUPN, CallerAppId)
| summarize
    EventCount      = count(),
    DistinctVaults  = dcount(Resource),
    DistinctSecrets = dcount(requestUri_s),
    VaultList       = make_set(Resource, 30),
    OperationList   = make_set(OperationName, 20),
    SecretList      = make_set(requestUri_s, 50),
    CallerIPList    = make_set(CallerIPAddress, 10),
    UserAgents      = make_set(clientInfo_s, 5),
    FirstAccess     = min(TimeGenerated),
    LastAccess      = max(TimeGenerated)
    by CallerKey, CallerObjectId
| where DistinctVaults >= MinVaultThreshold and DistinctSecrets >= MinSecretThreshold
| extend
    AccessDurationMinutes = datetime_diff('minute', LastAccess, FirstAccess),
    SecretsPerMinute = round(
        toreal(DistinctSecrets) / iff(datetime_diff('minute', LastAccess, FirstAccess) < 1, 1.0,
        toreal(datetime_diff('minute', LastAccess, FirstAccess))), 2)
| join kind=leftouter (
    IdentityInfo
    | where TimeGenerated > ago(14d)
    | summarize arg_max(TimeGenerated,*) by AccountObjectId
    | project AccountObjectId, AccountDisplayName, Department, JobTitle, RiskLevel
) on $left.CallerObjectId == $right.AccountObjectId
| project-reorder FirstAccess, LastAccess, AccessDurationMinutes, SecretsPerMinute, CallerKey,
    AccountDisplayName, Department, JobTitle, RiskLevel,
    DistinctVaults, DistinctSecrets, VaultList, OperationList, SecretList, CallerIPList, UserAgents
| sort by DistinctVaults desc, SecretsPerMinute desc

What it does: Identifies identities accessing three or more distinct vaults and five or more distinct secrets within a 24-hour window, with a SecretsPerMinute rate to quantify how aggressive the activity was.

Single-vault mass retrieval gets caught by basic rate alerting if you have it configured. Cross-vault enumeration doesn't. An attacker who compromised a privileged identity and is quietly mapping your entire secret estate across every vault in the subscription won't trigger anything unless you're specifically looking for it.

SecretsPerMinute is the most useful field here. Legitimate automation is predictable and runs at consistent intervals. A human working through vaults manually looks like bursts. An attacker scripting it looks like 2.69 secrets per minute across 3 vaults over 16 minutes.

What to look for:

  • Three or more vaults in VaultList from a single human identity is unusual in most environments (obviously not applicable for dev folks in a lot of cases)
  • OperationList containing both SecretGet and SecretList together means they enumerated the full list then retrieved individual secrets. That's tool-assisted behaviour
  • IPs in CallerIPList that aren't your known office/VPN ranges

KQL 4: Off-Hours, Weekend, or New IP Access

let lookback      = 14d;
let detectWindow  = 1d;
let WorkHourStart = 8;
let WorkHourEnd   = 18;
let AllowedAppIds = dynamic(["509e4652-da8d-478d-a730-e9d4a1996ca4","8cae6e77-e04e-42ce-b5cb-50d82bce26b1"]);
let OperationList = dynamic(["SecretGet","KeyGet","KeyDecrypt","CertificateGet","SecretList","KeyList"]);
let HistoricalPattern =
    AzureDiagnostics
    | where TimeGenerated between (ago(lookback) .. ago(detectWindow))
    | where ResourceType =~ "VAULTS" and ResultType =~ "Success"
    | where OperationName in (OperationList)
    | where not(identity_claim_appid_g in (AllowedAppIds))
    | extend CallerUPN = coalesce(
        column_ifexists("identity_claim_upn_s",""),
        column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s",""))
    | where isnotempty(CallerUPN)
    | summarize HistoricalIPs = make_set(CallerIPAddress, 100) by CallerUPN;
let RecentActivity =
    AzureDiagnostics
    | where TimeGenerated > ago(detectWindow)
    | where ResourceType =~ "VAULTS" and ResultType =~ "Success"
    | where OperationName in (OperationList)
    | where not(identity_claim_appid_g in (AllowedAppIds))
    | extend
        CallerUPN       = coalesce(
            column_ifexists("identity_claim_upn_s",""),
            column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s","")),
        CallerObjectId  = coalesce(
            column_ifexists("identity_claim_oid_g",""),
            column_ifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g","")),
        requestUri_s    = column_ifexists("requestUri_s",""),
        clientInfo_s    = column_ifexists("clientInfo_s",""),
        CallerIPAddress = column_ifexists("CallerIPAddress",""),
        HourOfDay       = hourofday(TimeGenerated),
        DayOfWeekInt    = toint(dayofweek(TimeGenerated) / 1d)
    | where isnotempty(CallerUPN)
    | extend IsWeekend  = (DayOfWeekInt == 0 or DayOfWeekInt == 6)
    | extend IsOffHours = (HourOfDay < WorkHourStart or HourOfDay >= WorkHourEnd);
RecentActivity
| join kind=leftouter HistoricalPattern on CallerUPN
| extend IsNewIP = iff(isnull(HistoricalIPs), true, not(set_has_element(HistoricalIPs, CallerIPAddress)))
| where IsOffHours == true or IsWeekend == true or IsNewIP == true
| summarize
    EventCount      = count(),
    Operations      = make_set(OperationName, 10),
    Vaults          = make_set(Resource, 20),
    SecretsAccessed = make_set(requestUri_s, 30),
    IPAddresses     = make_set(CallerIPAddress, 10),
    UserAgents      = make_set(clientInfo_s, 5),
    OffHoursEvents  = countif(IsOffHours == true),
    WeekendEvents   = countif(IsWeekend == true),
    NewIPEvents     = countif(IsNewIP == true),
    FirstSeen       = min(TimeGenerated),
    LastSeen        = max(TimeGenerated)
    by CallerUPN, CallerObjectId
| extend RiskScore =
    (iff(OffHoursEvents > 0, 25, 0)) +
    (iff(WeekendEvents  > 0, 25, 0)) +
    (iff(NewIPEvents    > 0, 50, 0))
| where RiskScore >= 50
| join kind=leftouter (
    BehaviorAnalytics
    | where TimeGenerated > ago(7d)
    | summarize
        BlastRadius            = any(tostring(UsersInsights.BlastRadius)),
        InvestigationPriority  = max(InvestigationPriority),
        AnomalousActivityCount = countif(
            tobool(ActivityInsights.FirstTimeUserPerformedAction) == true or
            tobool(ActivityInsights.FirstTimeConnectionFromCountryObservedInTenant) == true)
        by UserName
) on $left.CallerUPN == $right.UserName
| project-reorder FirstSeen, LastSeen, CallerUPN, RiskScore, BlastRadius, InvestigationPriority,
    OffHoursEvents, WeekendEvents, NewIPEvents, AnomalousActivityCount,
    EventCount, Vaults, Operations, SecretsAccessed, IPAddresses, UserAgents
| sort by RiskScore desc, InvestigationPriority desc

What it does: Baselines each identity's historical IP addresses over 14 days, then flags access outside business hours (08:00-18:00), on weekends, or from an IP that has never appeared in their history.

RiskScore is additive: off-hours adds 25, weekend adds 25, new IP adds 50. The query filters at >= 50 so a new IP alone fires it regardless of timing. If you're onboarding users regularly or have a large remote workforce, tune that threshold or add a time-based suppression after initial alert.

What to look for:

  • NewIPEvents = x, WeekendEvents = x means every single access was both from a new IP and on a weekend. That's not a misconfigured service account
  • The BehaviorAnalytics join adds BlastRadius and InvestigationPriorityBlastRadius = High means this identity, if compromised, has significant lateral movement potential
  • OffHoursEvents > 0 from an account that typically works 9-5 deserves investigation even at a low EventCount

KQL 5: Managed Identity from External IP

let lookback      = 1d;
let AllowedAppIds = dynamic(["509e4652-da8d-478d-a730-e9d4a1996ca4","8cae6e77-e04e-42ce-b5cb-50d82bce26b1"]);
let OperationList = dynamic(["SecretGet","KeyGet","KeyDecrypt","SecretList","CertificateGet","KeyList"]);
let AzureIPPrefixes = dynamic(["40.","52.","13.","20.","104.","168.63.","51.","23.","65.","70.","137.","157."]);
AzureDiagnostics
| where TimeGenerated > ago(lookback)
| where ResourceType =~ "VAULTS" and ResultType =~ "Success"
| where OperationName in (OperationList)
| where not(identity_claim_appid_g in (AllowedAppIds))
| extend
    CallerUPN       = coalesce(column_ifexists("identity_claim_upn_s",""), column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s","")),
    CallerObjectId  = coalesce(column_ifexists("identity_claim_oid_g",""), column_ifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g","")),
    CallerAppId     = column_ifexists("identity_claim_appid_g",""),
    requestUri_s    = column_ifexists("requestUri_s",""),
    clientInfo_s    = column_ifexists("clientInfo_s",""),
    CallerIPAddress = column_ifexists("CallerIPAddress","")
| where isempty(CallerUPN) and isnotempty(CallerAppId)
| where isnotempty(CallerIPAddress)
| extend StartsWithAzurePrefix = CallerIPAddress has_any (AzureIPPrefixes)
| extend IsPrivateIP = (CallerIPAddress startswith "10." or CallerIPAddress startswith "192.168." or CallerIPAddress startswith "172.")
| where StartsWithAzurePrefix == false and IsPrivateIP == false
| summarize
    EventCount      = count(),
    Operations      = make_set(OperationName, 10),
    Vaults          = make_set(Resource, 20),
    SecretsAccessed = make_set(requestUri_s, 30),
    ExternalIPs     = make_set(CallerIPAddress, 10),
    UserAgents      = make_set(clientInfo_s, 5),
    FirstSeen       = min(TimeGenerated),
    LastSeen        = max(TimeGenerated)
    by CallerObjectId, CallerAppId
| project-reorder FirstSeen, LastSeen, CallerObjectId, CallerAppId, EventCount, Vaults, Operations, SecretsAccessed, ExternalIPs, UserAgents
| sort by EventCount desc

What it does: Detects managed identity tokens (no human UPN) used to access Key Vault from IPs that are neither Azure datacenter ranges nor RFC1918 private ranges.

Managed identities are bound to Azure resources. When they authenticate to Key Vault, the call should always originate from within Azure infrastructure or private network ranges. If you see a managed identity's clientId appearing in Key Vault logs with an external residential or hosted IP, someone stole that token and is replaying it from outside Azure.

What to look for:

  • Any result here at all is high priority. Legitimate managed identities do not call Key Vault from external IPs. Full stop.
  • Cross-reference CallerAppId with your known service principals. Is this an app that should even have Key Vault access?
  • UserAgents containing python-requests or curl from a managed identity access is worth immediate investigation

KQL 6: UEBA-Correlated Sensitive Operations

let SensitiveOps = dynamic([
    "VaultDelete","VaultPatch",
    "KeyDelete","SecretDelete","SecretPurge","KeyPurge",
    "SecretBackup","KeyBackup","CertificateDelete","CertificatePurge",
    "SecretSet","KeyCreate","KeyImport",
    "CertificateCreate","CertificateImport","CertificateUpdate"
]);
let UEBASignals =
    BehaviorAnalytics
    | where TimeGenerated > ago(7d)
    | where ActivityType in ("Administrative","DataAccess","CloudLogon")
    | extend UserName = tolower(tostring(UserName))
    | summarize
        MaxPriority          = max(InvestigationPriority),
        BlastRadius          = any(tostring(UsersInsights.BlastRadius)),
        FirstTimeAction      = countif(tobool(ActivityInsights.FirstTimeUserPerformedAction) == true),
        FirstTimeFromCountry = countif(tobool(ActivityInsights.FirstTimeConnectionFromCountryObservedInTenant) == true),
        FirstTimeInTenant    = countif(tobool(ActivityInsights.FirstTimeActionPerformedInTenant) == true),
        AadUserId            = any(tostring(UsersInsights.AccountObjectID)),
        SourceCountries      = make_set(SourceIPLocation, 10)
        by UserName;
let KVActivity =
    AzureDiagnostics
    | where TimeGenerated > ago(7d)
    | where ResourceType =~ "VAULTS" and ResultType =~ "Success"
    | where OperationName in (SensitiveOps)
    | extend
        CallerUPN      = tolower(coalesce(
            column_ifexists("identity_claim_upn_s",""),
            column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s",""))),
        CallerObjectId = coalesce(
            column_ifexists("identity_claim_oid_g",""),
            column_ifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g","")),
        requestUri_s    = column_ifexists("requestUri_s",""),
        clientInfo_s    = column_ifexists("clientInfo_s",""),
        CallerIPAddress = column_ifexists("CallerIPAddress","")
    | where isnotempty(CallerUPN)
    | summarize
        KVEventCount = count(),
        KVOperations = make_set(OperationName, 20),
        KVVaults     = make_set(Resource, 20),
        KVIPs        = make_set(CallerIPAddress, 10),
        KVSecrets    = make_set(requestUri_s, 30),
        KVUserAgents = make_set(clientInfo_s, 5),
        FirstKVEvent = min(TimeGenerated),
        LastKVEvent  = max(TimeGenerated)
        by CallerUPN, CallerObjectId;
KVActivity
| join kind=leftouter UEBASignals on $left.CallerUPN == $right.UserName
| join kind=leftouter (
    IdentityInfo
    | where TimeGenerated > ago(14d)
    | summarize arg_max(TimeGenerated,*) by AccountObjectId
    | project AccountObjectId, Department, JobTitle, Manager, AccountDisplayName
) on $left.CallerObjectId == $right.AccountObjectId
| extend UEBARiskScore =
    (iff(BlastRadius =~ "High", 40, iff(isempty(BlastRadius), 10, 20))) +
    (iff(FirstTimeAction > 0, 20, 0))        +
    (iff(FirstTimeFromCountry > 0, 20, 0))   +
    (iff(FirstTimeInTenant > 0, 15, 0))      +
    (iff(KVEventCount > 20, 15, 5))          +
    coalesce(MaxPriority, 0)
| project-reorder FirstKVEvent, LastKVEvent, CallerUPN, AccountDisplayName, Department, JobTitle,
    Manager, BlastRadius, UEBARiskScore, MaxPriority, FirstTimeAction, FirstTimeFromCountry,
    FirstTimeInTenant, KVEventCount, KVOperations, KVVaults, KVSecrets, KVIPs, KVUserAgents, SourceCountries
| sort by UEBARiskScore desc

What it does: Joins Key Vault sensitive write/delete/backup operations against BehaviorAnalytics signals to surface identities performing destructive or high-value Key Vault operations who also have UEBA anomalies.

The UEBARiskScore is composite: 40 points for high blast radius, 20 for first-time action, 20 for first-time country, 15 for first-time tenant action, plus the raw InvestigationPriority from UEBA. It's additive intentionally. An account with a first-time country AND high blast radius AND deleting secrets scores heavily. An account just creating a new key with no UEBA signals scores low.

What to look for:

  • SecretDeleteSecretPurge, or KeyPurge from any identity should get immediate attention
  • CertificateBackup combined with high UEBARiskScore is a real concern; certificate private keys are high-value exfiltration targets
  • FirstTimeFromCountry > 0 combined with destructive operations is a strong signal

KQL 7: Failure Enumeration > Successful Read Chain

let lookback      = 1d;
let FailureWindow = 1h;
let MinFailures   = 3;
let Failures =
    AzureDiagnostics
    | where TimeGenerated > ago(lookback)
    | where ResourceType =~ "VAULTS"
    | where httpStatusCode_d in (403, 404, 409, 429)
    | extend
        CallerUPN       = coalesce(
            column_ifexists("identity_claim_upn_s",""),
            column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s","")),
        CallerAppId     = column_ifexists("identity_claim_appid_g",""),
        CallerIPAddress = column_ifexists("CallerIPAddress","")
    | extend CallerKey = iff(isnotempty(CallerUPN), tolower(CallerUPN), CallerAppId)
    | extend VaultKey  = toupper(Resource)
    | summarize
        FailureCount = count(),
        FailureCodes = make_set(tostring(toint(httpStatusCode_d)), 10),
        FailureOps   = make_set(OperationName, 10),
        FailureIPs   = make_set(CallerIPAddress, 10),
        FirstFailure = min(TimeGenerated),
        LastFailure  = max(TimeGenerated)
        by CallerKey, VaultKey
    | where FailureCount >= MinFailures;
let Successes =
    AzureDiagnostics
    | where TimeGenerated > ago(lookback)
    | where ResourceType =~ "VAULTS"
    | where httpStatusCode_d >= 200 and httpStatusCode_d < 300
    | where OperationName in ("SecretGet","SecretList","KeyGet","KeyList","KeyDecrypt","CertificateGet","CertificateList")
    | extend
        CallerUPN       = coalesce(
            column_ifexists("identity_claim_upn_s",""),
            column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s","")),
        CallerAppId     = column_ifexists("identity_claim_appid_g",""),
        requestUri_s    = column_ifexists("requestUri_s",""),
        CallerIPAddress = column_ifexists("CallerIPAddress","")
    | extend CallerKey = iff(isnotempty(CallerUPN), tolower(CallerUPN), CallerAppId)
    | extend VaultKey  = toupper(Resource)
    | summarize
        SuccessCount    = count(),
        SuccessOps      = make_set(OperationName, 10),
        SecretsAccessed = make_set(requestUri_s, 30),
        SuccessIPs      = make_set(CallerIPAddress, 10),
        FirstSuccess    = min(TimeGenerated),
        LastSuccess     = max(TimeGenerated)
        by CallerKey, VaultKey;
Failures
| join kind=inner Successes on CallerKey, VaultKey
| where FirstSuccess > LastFailure
| where (FirstSuccess - LastFailure) < FailureWindow
| extend TimeDeltaMinutes = datetime_diff('minute', FirstSuccess, LastFailure)
| join kind=leftouter (
    IdentityInfo
    | where TimeGenerated > ago(14d)
    | summarize arg_max(TimeGenerated,*) by AccountUPN
    | project AccountUPN, AccountDisplayName, Department, JobTitle, RiskLevel
) on $left.CallerKey == $right.AccountUPN
| project-reorder FirstFailure, LastFailure, FirstSuccess, TimeDeltaMinutes,
    VaultKey, CallerKey, AccountDisplayName, Department, RiskLevel,
    FailureCount, SuccessCount, FailureCodes, FailureOps, FailureIPs, SuccessIPs, SecretsAccessed
| sort by FailureCount desc

What it does: Identifies identities that generated multiple 404 or 403 responses on Key Vault data plane operations, then successfully read secrets within a 1-hour window.

A few things worth knowing about how Key Vault actually logs failures. Standard RBAC denials where someone lacks permissions at all are often not captured in Key Vault diagnostic logs at all because the rejection happens before the request reaches the vault. The failures that ARE reliably logged are authenticated requests for resources that don't exist: httpStatusCode_d = 404. An attacker probing for secret names, guessing credential names like db-passwordprod-api-keyadmin-secret, generates 404s before finding something real.

Do not filter on ResultType != "Success" for this. Azure logs 401 token challenges as ResultType = "Success" with httpStatusCode_d = 401. Every authenticated CLI command generates one of those before its real request. If you filter on ResultType, you'll either miss real failures or trigger on every single authenticated command in your environment.

What to look for:

  • TimeDeltaMinutes under 5 combined with FailureCount >= 5 is a strong enumeration signal
  • FailureCodes containing 404 confirms secret name guessing rather than permission denial
  • SecretsAccessed in the success phase containing high-value names like connection strings, certificates, or admin credentials

KQL 8: Certificate Operations Hunt

let lookback   = 7d;
let CertOps    = dynamic([
    "CertificateGet","CertificateList","CertificateGetDeleted","CertificateListDeleted",
    "CertificateRecover","CertificateDelete","CertificatePurge",
    "CertificateCreate","CertificateImport","CertificateUpdate",
    "CertificateBackup","CertificateRestore"
]);
let AllowedAppIds = dynamic(["509e4652-da8d-478d-a730-e9d4a1996ca4","8cae6e77-e04e-42ce-b5cb-50d82bce26b1"]);
AzureDiagnostics
| where TimeGenerated > ago(lookback)
| where ResourceType =~ "VAULTS" and ResultType =~ "Success"
| where OperationName in (CertOps)
| where not(identity_claim_appid_g in (AllowedAppIds))
| extend
    CallerUPN       = coalesce(column_ifexists("identity_claim_upn_s",""), column_ifexists("identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s","")),
    CallerObjectId  = coalesce(column_ifexists("identity_claim_oid_g",""), column_ifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g","")),
    CallerAppId     = column_ifexists("identity_claim_appid_g",""),
    requestUri_s    = column_ifexists("requestUri_s",""),
    clientInfo_s    = column_ifexists("clientInfo_s",""),
    CallerIPAddress = column_ifexists("CallerIPAddress","")
| extend CallerKey = iff(isnotempty(CallerUPN), CallerUPN, CallerAppId)
| extend IsSensitiveOp = OperationName in ("CertificateBackup","CertificateRestore","CertificateGetDeleted","CertificateRecover","CertificatePurge")
| summarize
    EventCount       = count(),
    SensitiveOpCount = countif(IsSensitiveOp == true),
    Operations       = make_set(OperationName, 20),
    Vaults           = make_set(Resource, 20),
    Certificates     = make_set(requestUri_s, 50),
    IPAddresses      = make_set(CallerIPAddress, 10),
    UserAgents       = make_set(clientInfo_s, 5),
    FirstSeen        = min(TimeGenerated),
    LastSeen         = max(TimeGenerated)
    by CallerKey, CallerObjectId
| join kind=leftouter (
    IdentityInfo
    | where TimeGenerated > ago(14d)
    | summarize arg_max(TimeGenerated,*) by AccountObjectId
    | project AccountObjectId, AccountDisplayName, Department, JobTitle, RiskLevel
) on $left.CallerObjectId == $right.AccountObjectId
| extend CertRiskSignal = iff(SensitiveOpCount > 0, "⚠️ Sensitive Cert Op Detected", "ℹ️ Read/List Only")
| project-reorder FirstSeen, LastSeen, CallerKey, AccountDisplayName, Department,
    CertRiskSignal, SensitiveOpCount, EventCount, Operations, Vaults, Certificates, IPAddresses, UserAgents
| sort by SensitiveOpCount desc, EventCount desc

What it does: Tracks the full certificate operation lifecycle against Key Vault: creates, lists, gets, backups, restores, deletes, purges, and recovery of deleted certs.

Certificates are the most overlooked exfiltration vector in Key Vault. Everyone focuses on secrets and keys. Certificate private keys stored in Key Vault are equally sensitive and the CertificateBackup operation exports the entire cert including the private key in a format that can be restored to any other vault. Microsoft's built-in Key Vault analytic templates don't cover backup/restore/recover operations at all.

IsSensitiveOp flags CertificateBackupCertificateRestoreCertificateGetDeletedCertificateRecover, and CertificatePurge specifically. Those five operations represent the highest-risk certificate activities. Reading or listing certs is lower risk. Backing them up is not.

What to look for:

  • CertRiskSignal = "⚠️ Sensitive Cert Op Detected" with SensitiveOpCount > 1 from a single identity in a short window
  • CertificateBackup followed by CertificateDelete is a classic exfiltration-then-destroy pattern
  • CertificateRecover on a recently deleted cert from a different IP than the one that deleted it

Before You Deploy These as Analytic Rules

A few things worth checking before you flip these into scheduled analytics.

  • Populate your AllowedAppIds list. The two app IDs in these queries are placeholders from the test environment. Add your actual service principals that legitimately access Key Vault at scale. Backup agents, certificate renewal services, and application registration service principals will all fire these rules if you don't exclude them.
  • KQL 1 and KQL 4 require at least 24 hours of baseline data before they produce meaningful results. The baseline window is configurable; extend it to 7 days if your environment is new.
  • KQL 4 will fire for literally everyone on day one of deployment because no historical IPs exist. Add a suppression or raise the RiskScore threshold to 75 initially, then lower it once the baseline is populated.
  • KQL 6 requires UEBA to be enabled and populated. If it isn't, you'll still get KV activity surfaced thanks to the leftouter join, but without the risk scoring enrichment.

Class dismissed.

Consent Preferences