Fixing the "Account Created and Deleted in Short Timeframe" Analytic Rule
Alright class.
Today it is "Account Created and Deleted in Short Timeframe". The rule pairs a "Delete user" event with an earlier "Add user" for the same object and fires when an account was created and then deleted inside the window. The story is that an attacker stood up a throwaway account, used it, and destroyed it to cover the trail. That story is real. The rule as written does not detect it. It detects an account being born and dying, which is one of the most common and most benign things that happens in a directory.
A Birth and a Death Is Not an Attack
Think about what creates and deletes accounts all day. Joiner-mover-leaver automation provisions an account and rolls it back when a record is corrected. HR sync creates a user from a feed, the feed changes, the user goes. B2B invitations bring a guest in for a meeting and remove them after. Test and licensing tooling spins accounts up and tears them down by design. Every one of these is an account created and deleted in a short window, and the original fires on all of them.
So the rule is an audit query. It reports a lifecycle event. In an automated tenant that is a steady stream of tickets for things that are working exactly as intended, and a SOC drowning in provisioning rollbacks is not watching for the one that matters.
What Did the Account Do?
The thing that separates a burner from a provisioning rollback is not the timing. It is what happened in between. An attacker's account is created, then used: it signs in, it acts, sometimes it is handed a role to make it useful, and then it is deleted. A provisioning rollback is created and deleted having done nothing at all. So the security signal is activity, and that is what the rebuild gates on.
Did the account sign in. Was it granted a directory role during its brief existence. An account that did neither is churn, and it should stay out of the queue no matter how fast it came and went. An account that signed in from somewhere and walked out with a role before being deleted is a different thing entirely.
There is a neat simplification here. The account only existed between its creation and its deletion, so it could not have signed in before it was made or after it was removed. Any sign-in or role assignment carrying that object id necessarily happened during its life. No time-window correlation is needed, presence is proof of in-life activity.
Weighting the Rest
Activity is the gate, and a couple of softer signals add weight on top: a life measured in minutes is worse than one measured in days, and one human running both the creation and the deletion is more suspicious than automation doing the same, because automation doing it is normal. A short, inactive account still does not fire. A short, used, escalated one fires hard. The weighting is in the query, tune it to taste.
The RiskIndicators Field
Same accumulating string as the rest of the series, mapped as a custom detail:
VeryShortLife | ShortLife | SignedInDuringLife | RoleGrantedDuringLife | SameUserActor
The combination to chase is SignedInDuringLife with RoleGrantedDuringLife on a VeryShortLife account run by SameUserActor: a burner created, signed in, escalated, and destroyed by one hand inside an hour.
The Blind Spot You Should Know About
The escalation signal watches directory-role grants. An attacker who gives the account capability another way, a privileged group, an app consent, an application credential, will not trip the role signal, so extend the operation list if those paths matter to you. And the account-only-existed-during-its-life simplification is clean, but it means a burner used purely as an object that never authenticates and never takes a role, a mail-enabled placeholder for instance, looks the same as churn and will not fire. Without activity there is nothing to separate it from a rollback, and inventing a signal there would only bring the noise back.
MITRE Mappings for the Updated Rule
Tactic: Persistence and Defense Evasion.
T1136.003 Create Account, Cloud Account. The creation of the account, the foothold the attacker intends to use.
T1070.009 Clear Persistence. The deletion, removing the account once it has served its purpose to erase the artifact.
T1098.003 Additional Cloud Roles. The role-grant path, the short-lived account being handed privilege before it goes.
Rule Settings
Run query every 60 minutes. Lookup data from the last 7 days. The two are separate: the rule evaluates deletions from the last hour, scoped inside the query, and reaches back across the lookup window only to find the matching creation and any in-life activity. Seven days is comfortably inside the platform limit and catches an attacker who waits a few days before deleting.
Over the first week, read what fires and tune the weights and threshold until provisioning churn is gone and only used accounts remain.
Medium severity is sensible, and surface the Score as a custom detail so an analyst can sort by it. Alert per result. Group by Account entity, the creating actor, so several burners spun up by one compromised admin collapse into a single incident.
Entity mapping:
- TargetName to Account (Name), TargetUPNSuffix to Account (UPNSuffix) for the short-lived account
- CreatedByName to Account (Name), CreatedByUPNSuffix to Account (UPNSuffix) for the actor
- CreatedByIP and DeletedByIP to IP (Address)
Custom details to surface in the incident: RiskIndicators, Score, Lifetime, SignInCount, SignInIPs, RoleOps, CreatedByUser, DeletedByUser, CreatedByApp, DeletedByApp.
KQL
// =====================================================================
// Account Created and Deleted in Short Timeframe - Entra ID
// =====================================================================
// Description : Pairs a user-account deletion with its earlier creation and surfaces it only when
// the short-lived account actually did something: signed in, or was granted a
// directory role during its life. A create/delete pair with no activity is treated
// as lifecycle churn and suppressed. Shortness of life and a single human running
// the whole lifecycle add weight.
// Type : Detection
//
// Tables : AuditLogs, SigninLogs, AADNonInteractiveUserSignInLogs
// Connectors : Microsoft Entra ID (AuditLogs, SignInLogs, NonInteractiveUserSignInLogs)
// License : Microsoft Sentinel
//
// Tuning : - DetectionWindow - recent deletions to evaluate; align to run frequency
// - LookbackWindow - how far back to find the creation and in-life activity
// - VeryShortLife / ShortLife - the two lifetime tiers
// - PrivilegedRoleOps - directory-role operations that count as escalation; extend for
// privileged groups, app consent, or credential adds if those paths matter to you
// - W_* weights and ScoreThreshold - shift these to fit your environment
//
// Known FPs : - Provisioning / HR-sync rollbacks - created and deleted with no activity, suppressed
// - B2B guest invited and removed without signing in - no activity, suppressed
// - An admin error: account created, used briefly, removed same day - low volume, expected
//
// Author : Bartosz Wysocki | https://www.itprofessor.cloud
// Version : 1.0 | 2026-06-17
// =====================================================================
let DetectionWindow = 1h; // recent deletions that trigger evaluation
let LookbackWindow = 7d; // window to find the matching creation and any in-life activity
let VeryShortLife = 1h;
let ShortLife = 24h;
let PrivilegedRoleOps = dynamic(["Add member to role", "Add eligible member to role"]);
// Scoring weights - activity carries the most; shortness and a single human actor add to it
let W_VeryShortLife = 3;
let W_ShortLife = 2;
let W_SignedIn = 3;
let W_RoleGranted = 4;
let W_SameUserActor = 2;
let ScoreThreshold = 4;
// User-account deletions in the recent window
let Deletions = AuditLogs
| where TimeGenerated > ago(DetectionWindow)
| where OperationName =~ "Delete user"
| mv-apply tr = TargetResources on (
where tostring(tr.type) =~ "User"
| extend UserId = tostring(tr.id), TargetUPN = tolower(tostring(tr.userPrincipalName))
)
| extend DeletedByUser = tolower(tostring(InitiatedBy.user.userPrincipalName)),
DeletedByApp = tostring(InitiatedBy.app.displayName),
DeletedByIP = tostring(InitiatedBy.user.ipAddress)
| project DeletionTime = TimeGenerated, UserId, TargetUPN, DeletedByUser, DeletedByApp, DeletedByIP;
// User-account creations in the lookback
let Creations = AuditLogs
| where TimeGenerated > ago(LookbackWindow)
| where OperationName =~ "Add user"
| mv-apply tr = TargetResources on (
where tostring(tr.type) =~ "User"
| extend UserId = tostring(tr.id)
)
| extend CreatedByUser = tolower(tostring(InitiatedBy.user.userPrincipalName)),
CreatedByApp = tostring(InitiatedBy.app.displayName),
CreatedByIP = tostring(InitiatedBy.user.ipAddress)
| project CreationTime = TimeGenerated, UserId, CreatedByUser, CreatedByApp, CreatedByIP;
// Create/delete pairs with a positive lifetime inside the window
let Pairs = materialize(
Deletions
| join kind=inner Creations on UserId
| extend Lifetime = DeletionTime - CreationTime
| where Lifetime between (time(0s) .. LookbackWindow));
// Restrict the activity scans to just the short-lived accounts (keeps the query cheap)
let CandidateIds = toscalar(Pairs | summarize make_set(UserId, 1000));
// Did the account sign in? It could only have done so during its life
let SignInActivity = union isfuzzy=true
(SigninLogs | where TimeGenerated > ago(LookbackWindow) | where UserId in (CandidateIds) | project UserId, IPAddress),
(AADNonInteractiveUserSignInLogs | where TimeGenerated > ago(LookbackWindow) | where UserId in (CandidateIds) | project UserId, IPAddress)
| summarize SignInCount = count(), SignInIPs = make_set(IPAddress, 20) by UserId;
// Was it granted a directory role during its life?
let RoleGrantActivity = AuditLogs
| where TimeGenerated > ago(LookbackWindow)
| where OperationName in~ (PrivilegedRoleOps)
| mv-apply tr = TargetResources on (
where tostring(tr.type) =~ "User"
| extend UserId = tostring(tr.id)
)
| where UserId in (CandidateIds)
| summarize RoleGrantCount = count(), RoleOps = make_set(OperationName, 5) by UserId;
Pairs
| join kind=leftouter SignInActivity on UserId
| join kind=leftouter RoleGrantActivity on UserId
| extend SignedIn = coalesce(SignInCount, 0) > 0
| extend RoleGranted = coalesce(RoleGrantCount, 0) > 0
| extend SameUserActor = isnotempty(CreatedByUser) and CreatedByUser == DeletedByUser
| extend LifeWeight = case(Lifetime < VeryShortLife, W_VeryShortLife, Lifetime < ShortLife, W_ShortLife, 0)
| extend Score = LifeWeight
+ toint(SignedIn) * W_SignedIn
+ toint(RoleGranted) * W_RoleGranted
+ toint(SameUserActor) * W_SameUserActor
| where Score >= ScoreThreshold
| extend RiskIndicators = trim(@"\s\|\s*$", strcat(
iff(Lifetime < VeryShortLife, "VeryShortLife | ", iff(Lifetime < ShortLife, "ShortLife | ", "")),
iff(SignedIn, "SignedInDuringLife | ", ""),
iff(RoleGranted, "RoleGrantedDuringLife | ", ""),
iff(SameUserActor, "SameUserActor | ", "")
))
| extend TargetName = tostring(split(TargetUPN, "@", 0)[0]), TargetUPNSuffix = tostring(split(TargetUPN, "@", 1)[0])
| extend CreatedByName = tostring(split(CreatedByUser, "@", 0)[0]), CreatedByUPNSuffix = tostring(split(CreatedByUser, "@", 1)[0])
| project
CreationTime, DeletionTime, Lifetime, Score, RiskIndicators,
TargetUPN, TargetName, TargetUPNSuffix, UserId,
SignInCount, SignInIPs, RoleOps,
CreatedByUser, CreatedByName, CreatedByUPNSuffix, CreatedByApp, CreatedByIP,
DeletedByUser, DeletedByApp, DeletedByIP
| sort by Score desc, Lifetime asc
Follow my repo - GitHub
What You Should Do Next
- Run it over the last few days in the Logs blade and read what fires. Whatever shows up with no sign-in and no role grant is the churn you are trying to escape, confirm the activity gate is holding it back. For testing you can widen the window in Logs, the deployed rule still caps at its lookup period.
- Extend the capability list if you need it. The role-grant signal watches directory roles. If attackers in your estate weaponise short-lived accounts through privileged groups, app consent, or added credentials, add those operations so escalation through them counts too.
- Tune the weights to your tenant. A directory role on a soon-deleted account is the strongest signal here, a sign-in the next, a sub-hour life and a single human actor the softer ones. Move the threshold until only real burners remain.
- Pivot on the actor, not the deleted account. The short-lived account is gone, so the lead is the identity that created and removed it. Investigate that account and the IPs it worked from.
Class dismissed