Fixing the "Guest Users Invited to Tenant by New Inviters" Analytic Rule
Alright class.
Today we are looking at "Guest Users Invited to Tenant by New Inviters", a content hub rule that promises to spot guest account abuse and instead delivers a daily list of colleagues who invited an external user for the first time in a fortnight. The premise is that a new inviter is a suspicious inviter. The premise is wrong, and the numbers show it quickly.
An audit rule wearing a detection costume
The entire detection is one comparison. Build a list of everyone who invited a guest in the previous window:
| where TimeGenerated between(ago(14d)..ago(1d))
| where OperationName =~ "Invite external user"
Then alert on anyone who is not on it:
| where isnotempty(InitiatingUserPrincipalName) and InitiatingUserPrincipalName !in (inviting_users)
One question: has this account invited a guest in the last 13 days. If not, alert.
Now think about how invitations happen in a real tenant. Guest invitation is not a specialist activity. Project managers share Teams channels, sales share proposals, HR invites contractors. Most of these people invite a handful of times a year. Take a tenant with 70 successful invitations a month spread across 40 accounts. If 30 of those accounts invite less often than fortnightly, which is typical, this rule raises 30 alerts a month for routine business. Worse, the baseline forgets people after 13 days, so the project manager who invites quarterly re-alerts every quarter, forever.
That is an audit trail. Audit trails have their place. This one arrives dressed as a Medium severity incident, and your SOC pays the difference.
What guest invitation abuse actually looks like
Hostile invitations come in three shapes. Persistence staging: an attacker holding a compromised account invites a mailbox they control, because the guest object survives the password reset that remediates the original account. Insider staging: a leaver invites their personal mailbox before sharing what they intend to take. Guest chains: where collaboration settings allow it, a guest invites further guests and your external population grows without any employee involved.
All three have observable properties the original ignores. Where the invitation lands, because a consumer mailbox is a different proposition to a partner domain. Who the inviter is, because a guest, a newly created account, or an account with fresh risk events changes the picture entirely. And how many invitations are going out. That is what the rebuild scores.
Known false positives
Recruiters and events teams invite candidates and speakers on consumer mailboxes as routine work. In the rebuild they alert exactly once, on their first invitation, because first-time inviter (2) plus consumer domain (3) reaches the threshold. From the next run they sit in the baseline and the consumer domain signal alone (3) stays silent. One alert per recruiter is a fair price, and it doubles as confirmation the signal works.
Onboarding waves score the burst signal only (3) for established inviters and stay silent. A new employee inviting a corporate partner in their first fortnight scores 4 and stays silent. Both outcomes are deliberate.
The Rebuild
| where RiskScore >= FireThreshold
Two signals clear the threshold alone. An inviter who is itself a guest account (5), because guest chains are rare, deliberate to configure, and worth a look every time. And an inviter with medium or high risk sign-ins in the last day (5), because a risky session followed by an external invitation is the persistence staging pattern in full view. Everything else stacks: consumer mail destination (3), invitation burst (3), inviter account created inside 14 days (2), first-time inviter (2). The original rule survives as a two point stacking signal, which is what it was always worth.
Degradation is silent by design. Without Entra ID P2 the risky sign-in lookup returns nothing and that signal never fires. IdentityInfo requires UEBA and this rule expects it; the guest inviter check also reads the #EXT# pattern in the UPN itself, so a stale directory snapshot cannot hide a guest, and the account age signal uses column_ifexists so schema variants degrade to silence rather than error.
The RiskIndicators Field
Inviter had 2 risky sign-ins in the last day | 3 invite(s) sent to consumer mail domains | First guest invitation from this account in 14 days
The combination to chase is risky sign-ins together with a consumer mail destination. That is a compromised account building its own back door, and it outranks whatever else is in the queue.
The Blind Spots You Should Know About
Invitations initiated by applications carry no user principal in InitiatedBy and are not scored. This is deliberate, because B2B automation would drown everything else, but if your tenant runs invitation automation you should watch that service principal separately.
The first-time signal can be cleared by an attacker who seeds one harmless invitation in an earlier run window. The other signals do not depend on it, which is exactly why it only carries two points.
The consumer mail list only catches domains on the list. An attacker who registers a plausible custom domain walks past it and needs the inviter state signals to score.
Account age and inviter memory are both capped at 14 days by the platform ceiling on the query period. An attacker prepared to wait a fortnight ages out of two signals.
MITRE Mappings for the Updated Rule
Persistence, T1136.003 (Create Account: Cloud Account). A redeemed invitation creates a new cloud identity in your tenant under external control, which is persistence in the most literal sense.
Rule Settings
Frequency: PT1H. Query period: P14D. Severity: Medium. Event grouping: alert per result, one alert per inviter per run. Incident grouping: matched on the Account entity over PT8H, so repeated scoring of the same inviter lands in one incident.
Entity mappings: Account (Name, UPNSuffix) for the inviter, Account (Name, UPNSuffix) for the sample invited guest, IP (Address) for the inviter IP.
Custom details: RiskScore, RiskIndicators, InviteCount, FreeMailInvites, InvitesLast24h, GuestDomains, InviterDept, InviterRoles, InviterType.
KQL
// =====================================================================
// Suspicious Guest Invitation Activity
// =====================================================================
// Description : Scores successful external guest invitations on inviter
// state (guest inviter, risky sign-ins, newly created
// account, first-time inviter) and invitation shape
// (consumer mail destination, volume burst); routine
// first-time corporate invitations stay below threshold.
// Type : Detection
//
// Tables : AuditLogs, SigninLogs, IdentityInfo
// Connectors : Microsoft Entra ID (Audit Logs, Sign-in Logs); UEBA for IdentityInfo
// License : Entra ID P1 for log export; P2 adds the risky sign-in signal
//
// Tuning : - Set the rule query period to P14D; the 14d inviter
// baseline only resolves if the rule looks back that far
// - DetectionWindow - must match the rule frequency (PT1H)
// - BurstThreshold - invitation volume treated as a burst
// - FreeMailDomains - extend with regional consumer providers
// - FireThreshold - 5 lets serious signals alert alone
//
// Known FPs : - Recruiters inviting candidates on consumer mailboxes
// alert once, then join the baseline and stay silent
// - Onboarding waves score burst only and stay silent for
// established inviters
//
// Author : Bartosz Wysocki | https://www.itprofessor.cloud
// Version : 1.0 | 2026-07-24
// =====================================================================
// Tuning variables
let DetectionWindow = 1h; // must match the rule frequency so each invitation is scored once
let BaselineWindow = 14d; // must match the rule query period; platform cap is P14D
let BurstWindow = 1d; // window for counting invitation volume per inviter
let BurstThreshold = 5; // invitations within BurstWindow treated as a burst
let IdentityLookback = 14d; // how far back to resolve IdentityInfo snapshots
let RiskLookback = 1d; // window for risky sign-ins by the inviter; needs Entra ID P2
// Scoring: serious signals clear FireThreshold alone; soft signals only stack
let FireThreshold = 5;
let W_GuestInviter = 5; // the inviter is itself a guest account
let W_RiskyInviter = 5; // the inviter had medium or high risk sign-ins in the last day
let W_FreeMailGuest = 3; // an invited guest lands on a consumer mail domain
let W_InviteBurst = 3; // invitation volume at or above BurstThreshold in BurstWindow
let W_NewAccount = 2; // the inviter account was created inside the last 14 days
let W_NewInviter = 2; // no invitations from this account in the prior baseline
// Consumer mail domains that rarely belong to a business partner
let FreeMailDomains = dynamic(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com", "yahoo.com", "ymail.com", "icloud.com", "me.com", "aol.com", "proton.me", "protonmail.com", "gmx.com", "gmx.de", "mail.com", "zoho.com", "yandex.com", "qq.com", "163.com", "wp.pl", "onet.pl", "interia.pl", "o2.pl"]);
// All successful external invitations in the query period, one row per invited guest
let Invites = materialize(
AuditLogs
| where TimeGenerated > ago(BaselineWindow)
| where OperationName =~ "Invite external user"
| where Result =~ "success"
| extend InviterUPN = tostring(parse_json(tostring(InitiatedBy.user)).userPrincipalName),
InviterId = tostring(parse_json(tostring(InitiatedBy.user)).id),
InviterIP = tostring(parse_json(tostring(InitiatedBy.user)).ipAddress)
| where isnotempty(InviterUPN)
| mv-apply Target = TargetResources on (
where tostring(Target.type) =~ "User"
| project GuestUPN = tostring(Target.userPrincipalName), GuestId = tostring(Target.id)
| take 1
)
| extend GuestDomain = tolower(iff(GuestUPN has "#EXT#", extract(@"_([^_]+)#EXT#", 1, GuestUPN), tostring(split(GuestUPN, "@", 1)[0])))
| project TimeGenerated, InviterUPN, InviterId, InviterIP, GuestUPN, GuestId, GuestDomain
);
// Accounts that already invited guests before the current detection window
let KnownInviters = Invites
| where TimeGenerated between (ago(BaselineWindow) .. ago(DetectionWindow))
| distinct InviterUPN;
// Invitation volume per inviter over the burst window
let BurstCounts = Invites
| where TimeGenerated > ago(BurstWindow)
| summarize InvitesLast24h = count() by InviterUPN;
// Inviters with risky sign-ins; empty without Entra ID P2, which silences the signal
let RiskyInviters = SigninLogs
| where TimeGenerated > ago(RiskLookback)
| where RiskLevelDuringSignIn in~ ("medium", "high")
| summarize RiskySignIns = count() by UserPrincipalName;
// Latest directory snapshot per account for inviter context
let InviterIdentity = IdentityInfo
| where TimeGenerated > ago(IdentityLookback)
| summarize arg_max(TimeGenerated, Department, JobTitle, AssignedRoles, UserType) by AccountUPN
| extend AccountCreated = column_ifexists("CreatedDateTime", datetime(null))
| project AccountUPN, InviterDepartment = Department, InviterJobTitle = JobTitle,
InviterRoles = tostring(AssignedRoles), InviterType = UserType, AccountCreated;
Invites
| where TimeGenerated > ago(DetectionWindow)
| extend IsFreeMail = GuestDomain in~ (FreeMailDomains)
| extend SampleRank = toint(IsFreeMail)
| summarize StartTime = min(TimeGenerated), EndTime = max(TimeGenerated),
InviteCount = count(),
FreeMailInvites = countif(IsFreeMail),
GuestDomains = strcat_array(make_set(GuestDomain, 20), ", "),
arg_max(SampleRank, GuestUPN),
take_any(InviterId), take_any(InviterIP)
by InviterUPN
| join kind=leftouter (InviterIdentity) on $left.InviterUPN == $right.AccountUPN
| join kind=leftouter (RiskyInviters) on $left.InviterUPN == $right.UserPrincipalName
| join kind=leftouter (BurstCounts) on InviterUPN
| extend RiskySignIns = coalesce(RiskySignIns, 0), InvitesLast24h = coalesce(InvitesLast24h, 0)
| extend IsGuestInviter = InviterType =~ "Guest" or InviterUPN has "#EXT#"
| extend IsRiskyInviter = RiskySignIns > 0
| extend IsBurst = InvitesLast24h >= BurstThreshold
| extend IsNewAccount = isnotempty(AccountCreated) and AccountCreated > ago(BaselineWindow)
| extend IsNewInviter = InviterUPN !in (KnownInviters)
| extend RiskScore =
iff(IsGuestInviter, W_GuestInviter, 0)
+ iff(IsRiskyInviter, W_RiskyInviter, 0)
+ iff(FreeMailInvites > 0, W_FreeMailGuest, 0)
+ iff(IsBurst, W_InviteBurst, 0)
+ iff(IsNewAccount, W_NewAccount, 0)
+ iff(IsNewInviter, W_NewInviter, 0)
| where RiskScore >= FireThreshold
| extend Risk_1 = iff(IsGuestInviter, "Inviter is itself a guest account", "")
| extend Risk_2 = iff(IsRiskyInviter, strcat("Inviter had ", RiskySignIns, " risky sign-ins in the last day"), "")
| extend Risk_3 = iff(FreeMailInvites > 0, strcat(FreeMailInvites, " invite(s) sent to consumer mail domains"), "")
| extend Risk_4 = iff(IsBurst, strcat(InvitesLast24h, " invitations in the last day"), "")
| extend Risk_5 = iff(IsNewAccount, "Inviter account created inside the last 14 days", "")
| extend Risk_6 = iff(IsNewInviter, "First guest invitation from this account in 14 days", "")
| 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, " | "), "")))
| extend InviterName = tostring(split(InviterUPN, "@", 0)[0]), InviterUPNSuffix = tostring(split(InviterUPN, "@", 1)[0])
| extend GuestName = tostring(split(GuestUPN, "@", 0)[0]), GuestUPNSuffix = tostring(split(GuestUPN, "@", 1)[0])
| project StartTime, EndTime, InviterUPN, InviterName, InviterUPNSuffix, InviterId, InviterIP,
InviterDepartment, InviterJobTitle, InviterRoles, InviterType,
GuestUPN, GuestName, GuestUPNSuffix, GuestDomains, InviteCount, FreeMailInvites, InvitesLast24h,
RiskScore, RiskIndicators
| sort by RiskScore desc, EndTime desc
You can also download this as an analytic rule and import it directly to Sentinel.
Follow my repo - GitHub
What You Should Do Next
- Confirm AuditLogs, SigninLogs and IdentityInfo are all populated in your workspace. UEBA feeds IdentityInfo and without it this rule does not run, so this decides usability before anything else.
- Open External collaboration settings in Entra and check who is allowed to invite. If guests can invite guests, the five point signal is live in your tenant and you want to know that before the first alert, not after.
- Extend FreeMailDomains with the consumer providers your region actually uses. The shipped list leans western European because that is where my readers live.
- Run the query in Logs with the score gate commented out for a week and read the score distribution. If routine business sits at 4 you are well placed; if it sits at 5, raise FireThreshold before enabling the rule.
Class dismissed.