Fixing the "Account Created From Non-Approved Sources" Analytic Rule

Fixing the "Account Created From Non-Approved Sources" Analytic Rule

Alright class.

A companion piece to the last lesson, from the same Microsoft security-operations page. This one is "Account created from non-approved sources": it baselines the domains your tenant normally sees and fires when a new account arrives from a domain outside that baseline. The idea is respectable. New accounts from never-seen domains are how B2B guest persistence starts. Then you look at where the baseline comes from, and the design starts working for the other side.

A Baseline the Attacker Can Join

The approved list is built from successful sign-ins over the trailing seven days:

let core_domains = (SigninLogs
  | where TimeGenerated > ago(7d)
  | where ResultType == 0
  | extend domain = tolower(split(UserPrincipalName, "@")[1])
  | summarize by tostring(domain));

Look at what qualifies a domain as approved: one successful sign-in in the last week. Now run the timeline as an attacker. You invite a guest from your freshly registered domain and redeem it within minutes, because redemption is an email click. The rule runs once a day, so it only catches the creation if that daily run happens to land in the gap between the invite and your first sign-in, and you control the size of the gap. Squeeze it to ten minutes and the rule has ten minutes out of twenty four hours to get lucky. It is a race, and one side owns the stopwatch.

Whether or not that first alert fires, the damage lands at the first successful sign-in. From that point your domain sits in core_domains, and every account created from it while it keeps authenticating is pre-approved. The list of non-approved sources ends up curated by whoever manages to sign in, which is the one group it exists to defend against.

The rolling window is also forgetful in the other direction. A legitimate partner whose people do not sign in for eight days drops out of the baseline, and the next account created from that partner alerts as a non-approved source. The design trusts attackers and distrusts partners, on the same seven day memory.

A baseline must be built from something the attacker cannot cheaply join. A successful authentication is the cheapest artifact an attacker with a working guest account produces.

What a Novel Domain Actually Means

Here is the part the original never engages with: you cannot create a member account with a UPN suffix that is not a verified domain of the tenant. So when a genuinely new domain shows up on a created account, it is one of exactly two stories.

If the account is a guest, this is B2B collaboration, and a first-time partner domain is a Tuesday.

If the account is a member, a verified domain your directory has never used just produced its first account. Either someone added that domain recently, or an old parked domain woke up; both are worth eyes. Adding a domain and setting federation on it is the classic tenant backdoor, the NOBELIUM move, because a federated domain lets the attacker mint tokens for anyone. The original rule treats this identically to a guest invite.

One rule, two threats, and the severity lives in which one you are looking at.

First Contact Is Not an Incident

The false positive problem is baked into the premise: novelty alone is the noisiest gate in identity. So novelty is demoted to candidacy, and the weights decide what fires. A bare first-time partner guest during business hours scores zero and stays silent, because that event is indistinguishable from commerce. If that quiet guest is later handed a privileged role, the previous lesson's rule fires on the empowerment; that is the hand-off between the two detections, and it is deliberate.

What clears the line on its own: a member account on a never-seen domain, the new domain matching a recent verified-domain or federation configuration change, a lookalike containing one of your own domain labels, and bulk creation from a single new domain. Guest-initiated invites, consumer mail sources and off-hours timing are amplifiers that fire only in combination.

The baseline is rebuilt so the sign-in loophole cannot exist. It comes from the standing directory in IdentityInfo, everyone who already exists by UPN and mail domain, plus creation history from earlier in the audit lookback, with the current window excluded so creations cannot vouch for themselves. Sign-ins are out of the loop entirely, and the only door into this baseline is a creation event, which is precisely the thing the rule scores. Because the population is a standing one rather than an activity window, a partner does not fall out of trust by going quiet for a week. And the rule runs hourly, so the event is evaluated long before anything that happens afterwards could matter.

There is a pleasant side effect: the rule self-quiets. A planned onboarding of a new brand or partner domain costs you exactly one incident, then the domain is in the baseline and never alerts again.

The Rebuild

The gate is the score, not the novelty:

| where RiskScore >= FireThreshold

Degradation is graceful. Without UEBA the directory baseline is gone and the lookalike check with it, but thirteen days of creation history still baselines, and every remaining signal reads straight from AuditLogs. Signals fire on positive evidence only, so a thin lookup never invents an alert.

The RiskIndicators Field

Same accumulating string as the rest of the series, mapped as a custom detail:

MemberOnNewDomain | DomainConfigChange | LookalikeDomain | BulkFromNewDomain | GuestInviter | ConsumerMailDomain | OffHoursActivity

The combination to chase is MemberOnNewDomain with DomainConfigChange: a member account materialising on a domain that was verified or federated within the last fortnight. That is the backdoor domain pattern end to end, and it goes to the top of the queue at score eleven.

The Blind Spots You Should Know About

The lookalike check derives your own domain labels and matches them as terms, so contoso-secure.com is caught but the homoglyph cont0so.com is not. It is a bonus, not coverage.

Self-quieting cuts both ways. Once a domain fires, it enters the baseline, so a second wave from the same domain a week later rides on your handling of the first incident rather than a fresh alert. If you close the first one as benign without checking, the domain is approved from then on.

Cross-tenant synchronisation onboarding a new partner tenant looks exactly like bulk novel-domain creation. Expect one incident per new partner or exclude the sync principal. And on-premises account creation, event 4720, is a different rule on SecurityEvent, not this one.

MITRE Mappings for the Updated Rule

Tactics: Persistence, Initial Access, Defense Evasion.

T1136.003 Create Account, Cloud Account. The core event, an account stood up from an external source to keep access.

T1199 Trusted Relationship. The guest path, abusing the B2B trust channel as the way in.

T1484.002 Domain or Tenant Policy Modification, Trust Modification. The member path, where the novel domain traces back to a domain verification or federation change.

Rule Settings

Run every 60 minutes, and set the query period to 14 days so the directory, creation-history and domain-operation lookups resolve. Medium severity, with RiskScore and the indicators separating the backdoor-domain cases from the bulk-guest cases. Alert per result, since the query collapses to one row per new domain and initiator. Group by the Account entity over 8 hours so a spree across runs becomes one incident.

Entity mapping:

Name to Account (Name), UPNSuffix to Account (UPNSuffix)
PrimaryIp to IP (Address)
NewDomain to DNS (DomainName)

Custom details: RiskScore, RiskIndicators, NewDomain, CreatedCount, MemberCount, SampleTargets, DomainConfigOps, LookalikeHit, InviterIsGuest, InitiatorType, OffHoursCount.

KQL

// =====================================================================
// Account Created From Unfamiliar Domain - Entra ID
// =====================================================================
// Description : Detects accounts created with a source domain never seen in the tenant,
//               scored by what makes the novelty dangerous: a member account on a domain the
//               tenant only just verified (federation backdoor territory), a recent domain or
//               federation configuration change matching the new domain, a lookalike of your
//               own domains, bulk creation from one new domain, a guest doing the inviting,
//               consumer mail sources and off-hours timing. The baseline is the standing
//               directory plus prior creation history, not sign-ins, so an attacker cannot
//               approve their own domain by authenticating with it.
// Type        : Detection
//
// Tables      : AuditLogs, IdentityInfo
// Connectors  : Microsoft Entra ID (AuditLogs), Microsoft Sentinel UEBA (IdentityInfo)
// License     : Microsoft Sentinel; Microsoft Entra ID P1 (audit logs),
//               P2 recommended (UEBA / IdentityInfo for the directory-domain baseline)
//
// Tuning      : - Set the rule query period to P14D; the 14d baselines only resolve if the rule looks back that far
//               - BulkDomainThreshold - distinct accounts from one new domain that count as bulk
//               - FreeMailDomains - consumer mail providers; empty the list if consumer guests are routine for you
//               - W_GuestInviter - raise it if guests cannot invite in your tenant, so any hit fires alone
//               - BusinessStart / BusinessEnd - local working hours; TimeGenerated is UTC, shift to your tenant
//               - TenantLabels strlen filter - raise from 5 if your domain labels are short and generic
//
// Known FPs   : - Planned onboarding of a new brand or partner domain - fires once, then the domain enters
//                 the baseline and never repeats; close as expected change
//               - Cross-tenant synchronisation onboarding a partner tenant - bulk novel-domain accounts;
//                 exclude the sync principal or expect one incident per new partner
//               - Contractors on consumer mail - amplifier only; zero the FreeMail weight if routine
//
// Author      : Bartosz Wysocki | https://www.itprofessor.cloud
// Version     : 1.0 | 2026-07-02
// =====================================================================
let DetectionWindow = 1h;
let BaselineLookback = 14d;       // prior creation history for the domain baseline
let IdentityLookback = 14d;       // full UEBA sync cycle; the standing directory population
let DomainOpsLookback = 14d;      // window for verified-domain and federation configuration changes
let BulkDomainThreshold = 3;      // distinct accounts from one new domain that count as bulk
let BusinessStart = 7;
let BusinessEnd = 19;
let FireThreshold = 4;
// Scoring weights - the impossible-without-a-verified-domain case and the config follow-on carry the most
let W_MemberDomain = 6;           // member account on a domain the tenant never had
let W_DomainConfig = 5;           // the new domain matches a recent domain or federation configuration change
let W_Lookalike = 4;              // the new domain contains one of your own domain labels
let W_BulkDomain = 4;             // bulk creation from a single new domain
let W_GuestInviter = 3;           // the initiator is itself a guest account
let W_FreeMail = 2;               // consumer mail source domain
let W_OffHours = 1;               // activity outside working hours
let FreeMailDomains = dynamic([
"gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com",
"yahoo.com", "icloud.com", "proton.me", "protonmail.com", "aol.com",
"gmx.com", "mail.com", "yandex.com"
]);
// Home domain of a UPN. Invited guests carry the mangled form (the address's @ becomes the final
// underscore before #EXT#), so take the last segment; plain addresses split on @. Handles both shapes.
let HomeDomain = (u:string) {
    tolower(iff(u contains "#EXT#", extract(@"_([^_]+)#EXT#", 1, u), tostring(split(u, "@")[1])))
};
// Every domain the tenant already knows: the standing directory (UPN and mail) plus accounts
// created earlier in the lookback, excluding the current window so creations cannot self-baseline
let KnownDomains = union
    (IdentityInfo
        | where TimeGenerated > ago(IdentityLookback)
        | extend Ds = pack_array(HomeDomain(AccountUPN), tolower(tostring(split(MailAddress, "@")[1])))
        | mv-expand D = Ds to typeof(string)),
    (AuditLogs
        | where TimeGenerated between (ago(BaselineLookback) .. ago(DetectionWindow))
        | where OperationName =~ "Add user"
        | where Result =~ "success"
        | mv-apply tr = TargetResources on (
            where tostring(tr.type) =~ "User"
            | project TU = tostring(tr.userPrincipalName)
          )
        | project D = HomeDomain(TU))
    | where isnotempty(D)
    | distinct D;
// Second-level labels of your own member domains, for the lookalike check
let TenantLabels = toscalar(
    IdentityInfo
    | where TimeGenerated > ago(IdentityLookback)
    | where UserType =~ "Member"
    | where AccountUPN !contains "#EXT#"
    | extend D = tolower(tostring(split(AccountUPN, "@")[1]))
    | extend L = tostring(split(D, ".")[0])
    | where strlen(L) >= 5
    | summarize make_set(L, 50));
// Domain and federation configuration changes; a new domain matching one of these is the follow-on
let DomainOps = AuditLogs
    | where TimeGenerated > ago(DomainOpsLookback)
    | where OperationName in~ ("Add verified domain", "Add unverified domain", "Verify domain",
                               "Set federation settings on domain", "Set domain authentication")
    | mv-apply tr = TargetResources on (
        project OpDomain = tolower(tostring(tr.displayName))
      )
    | where isnotempty(OpDomain)
    | summarize DomainOpNames = make_set(OperationName, 10), LastDomainOp = max(TimeGenerated) by OpDomain;
// Successful account creations in the detection window whose source domain is new to the tenant
AuditLogs
| where TimeGenerated > ago(DetectionWindow)
| where OperationName =~ "Add user"
| where Result =~ "success"
| extend InitiatorUPN = tolower(tostring(InitiatedBy.user.userPrincipalName))
| extend InitiatorApp = tostring(InitiatedBy.app.displayName)
| extend Initiator = iff(isnotempty(InitiatorUPN), InitiatorUPN, InitiatorApp)
| extend InitiatorType = iff(isnotempty(InitiatorUPN), "User", "App")
| extend InitiatorIp = iff(isnotempty(tostring(InitiatedBy.user.ipAddress)), tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))
| where isnotempty(Initiator)
| mv-apply tr = TargetResources on (
    where tostring(tr.type) =~ "User"
    | project TargetUPN = tolower(tostring(tr.userPrincipalName)), TargetId = tostring(tr.id)
  )
| extend NewDomain = HomeDomain(TargetUPN)
| where isnotempty(NewDomain)
| where NewDomain !in (KnownDomains)
| extend IsGuestTarget = TargetUPN contains "#EXT#"
| extend HourOfDay = datetime_part("Hour", TimeGenerated)
| extend OffHoursEvent = HourOfDay < BusinessStart or HourOfDay >= BusinessEnd or dayofweek(TimeGenerated) in (0d, 6d)
| summarize
    StartTime = min(TimeGenerated),
    EndTime = max(TimeGenerated),
    CreatedCount = dcount(TargetId),
    MemberCount = dcountif(TargetId, IsGuestTarget == false),
    SampleTargets = make_set(TargetUPN, 10),
    OffHoursCount = countif(OffHoursEvent),
    IpSet = make_set(InitiatorIp, 10),
    PrimaryIp = take_any(InitiatorIp)
  by NewDomain, Initiator, InitiatorType, InitiatorApp
| join kind=leftouter DomainOps on $left.NewDomain == $right.OpDomain
| extend InviterIsGuest = Initiator contains "#EXT#"
| extend sMember = MemberCount > 0
| extend sDomainOp = isnotempty(LastDomainOp)
| extend sLookalike = NewDomain has_any (TenantLabels)
| extend sBulk = CreatedCount >= BulkDomainThreshold
| extend sGuestInviter = InviterIsGuest
| extend sFreeMail = NewDomain in~ (FreeMailDomains)
| extend sOffHours = OffHoursCount > 0
| extend RiskScore =
      iff(sMember, W_MemberDomain, 0)
    + iff(sDomainOp, W_DomainConfig, 0)
    + iff(sLookalike, W_Lookalike, 0)
    + iff(sBulk, W_BulkDomain, 0)
    + iff(sGuestInviter, W_GuestInviter, 0)
    + iff(sFreeMail, W_FreeMail, 0)
    + iff(sOffHours, W_OffHours, 0)
| where RiskScore >= FireThreshold
| extend Name = tostring(split(Initiator, "@", 0)[0]), UPNSuffix = tostring(split(Initiator, "@", 1)[0])
| extend Risk_1 = iff(sMember, "MemberOnNewDomain", "")
| extend Risk_2 = iff(sDomainOp, "DomainConfigChange", "")
| extend Risk_3 = iff(sLookalike, "LookalikeDomain", "")
| extend Risk_4 = iff(sBulk, "BulkFromNewDomain", "")
| extend Risk_5 = iff(sGuestInviter, "GuestInviter", "")
| extend Risk_6 = iff(sFreeMail, "ConsumerMailDomain", "")
| extend Risk_7 = iff(sOffHours, "OffHoursActivity", "")
| extend RiskIndicators = trim(@"\s\|\s*$", strcat(
    iff(isnotempty(Risk_1), strcat(Risk_1, " | "), ""),
    iff(isnotempty(Risk_2), strcat(Risk_2, " | "), ""),
    iff(isnotempty(Risk_3), strcat(Risk_3, " | "), ""),
    iff(isnotempty(Risk_4), strcat(Risk_4, " | "), ""),
    iff(isnotempty(Risk_5), strcat(Risk_5, " | "), ""),
    iff(isnotempty(Risk_6), strcat(Risk_6, " | "), ""),
    iff(isnotempty(Risk_7), strcat(Risk_7, " | "), "")
))
| extend DomainConfigOps = iff(sDomainOp, strcat_array(DomainOpNames, " / "), "")
| extend LookalikeHit = sLookalike
| project
    StartTime, EndTime, RiskScore, RiskIndicators,
    NewDomain, Initiator, Name, UPNSuffix, InitiatorType, PrimaryIp, IpSet,
    CreatedCount, MemberCount, SampleTargets,
    DomainConfigOps, LookalikeHit, InviterIsGuest, OffHoursCount
| sort by RiskScore desc, EndTime desc

Analytic rule ready to import

Follow my repo - GitHub

What You Should Do Next

Run the query over the last day before deploying, and if you want a wider test, raise the lookback in Logs without touching the rule. What fires should be lookalikes, bulk activity and the odd consumer-mail cluster. If your regular provisioning surfaces, it is creating accounts on domains your directory somehow does not contain.

Check the domain-operation trail while you are there. If DomainConfigChange ever fires, treat the domain verification and federation events behind it as the incident, not the account.

Zero the consumer-mail weight if gmail contractors are your normal. It is an amplifier, and amplifiers you disagree with are just noise with a smaller font.

Pair this with the previous lesson's rule. This one watches where accounts come from, that one watches what new accounts are given. The quiet guest this rule deliberately lets pass is exactly the account that rule catches the moment it gains power.

Class dismissed.

Consent Preferences