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 AzureDiagnostics, IdentityInfo, 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, SecretURIsWhat 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 Department, JobTitle, 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
Vaultsset, especially production ones OperationscontainingSecretGeton 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 ascWhat 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:
MinTimeDeltaMinmodified + read the secret within a short timeframe, something we would like to know aboutHighRisk = truefires when that delta is under 10 minutes AND more than 3 unique secrets were accessedChangeType = VaultPutis 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 descWhat 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
VaultListfrom a single human identity is unusual in most environments (obviously not applicable for dev folks in a lot of cases) OperationListcontaining bothSecretGetandSecretListtogether means they enumerated the full list then retrieved individual secrets. That's tool-assisted behaviour- IPs in
CallerIPListthat 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 descWhat 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 = xmeans every single access was both from a new IP and on a weekend. That's not a misconfigured service account- The
BehaviorAnalyticsjoin addsBlastRadiusandInvestigationPriority.BlastRadius = Highmeans this identity, if compromised, has significant lateral movement potential OffHoursEvents > 0from an account that typically works 9-5 deserves investigation even at a lowEventCount

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 descWhat 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
CallerAppIdwith your known service principals. Is this an app that should even have Key Vault access? UserAgentscontainingpython-requestsor 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 descWhat 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:
SecretDelete,SecretPurge, orKeyPurgefrom any identity should get immediate attentionCertificateBackupcombined with highUEBARiskScoreis a real concern; certificate private keys are high-value exfiltration targetsFirstTimeFromCountry > 0combined 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 descWhat 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-password, prod-api-key, admin-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:
TimeDeltaMinutesunder 5 combined withFailureCount >= 5is a strong enumeration signalFailureCodescontaining404confirms secret name guessing rather than permission denialSecretsAccessedin 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 descWhat 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 CertificateBackup, CertificateRestore, CertificateGetDeleted, CertificateRecover, 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"withSensitiveOpCount > 1from a single identity in a short windowCertificateBackupfollowed byCertificateDeleteis a classic exfiltration-then-destroy patternCertificateRecoveron 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
AllowedAppIdslist. 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
RiskScorethreshold 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
leftouterjoin, but without the risk scoring enrichment.
Class dismissed.
