The Guests Who Never Left

The Guests Who Never Left

All right class.

Every tenant has them. A guest invited in 2019 for a migration project that finished in 2020, invited by somebody who left in 2022, still sitting in a Teams channel with the commercial folder attached to it.

Nobody removed them because nobody owns them. The project sponsor moved on, the partner contract lapsed, and the account quietly survived all of it.

Why a Guest Is Not Just Another User

An internal account is protected by controls you own. Your Conditional Access, your MFA policy, your offboarding process.

A guest account is protected by controls somebody else owns. If a partner's employee gets phished, the attacker inherits that person's access to your tenant, and you find out when they use it. You have no view of the partner's MFA posture, no view of their offboarding, and no say in either. Guest access exports your attack surface to an organisation you cannot audit.

Two shapes matter. The compromised partner, which is how this gets used from outside. And the leaver, who invites their own personal address as a guest a fortnight before resigning and keeps a working door into your Teams after their account is disabled. Offboarding almost never checks for that one.

The Column That Tells You Someone Is a Guest Has Three Names

Before any of that, a practical problem.

IdentityInfo exists in three schema variants. Log Analytics in the Azure portal uses AccountUPN and UserType. Advanced hunting in the Defender portal uses AccountUpn and TenantMembershipType. The unified version merges the two. Microsoft's own guidance is to check your workspace before writing the query, which is a polite way of saying the schema depends on where you are standing.

So write it portably. column_ifexists costs nothing and the query survives the move to the Defender portal.

// Guests with no authentication in the window, most privileged and most dormant first
let Lookback = 30d;                  // activity window that defines dormant
let IdentityLookback = 14d;          // one full UEBA resync cycle
// Portable across the Log Analytics, advanced hunting and unified schemas
let Guests = IdentityInfo
    | where TimeGenerated > ago(IdentityLookback)
    | extend Upn = tolower(coalesce(column_ifexists("AccountUPN", ""), column_ifexists("AccountUpn", "")))
    | extend MemberType = coalesce(column_ifexists("UserType", ""), column_ifexists("TenantMembershipType", ""))
    | where MemberType =~ "Guest"
    | where isnotempty(Upn)
    | summarize arg_max(TimeGenerated, IsAccountEnabled, AccountDisplayName, AssignedRoles, GroupMembership, AccountObjectId) by Upn;
// Both tables, because a guest with a refreshing token never appears in SigninLogs
let Active = union isfuzzy=true
      (SigninLogs | where TimeGenerated > ago(Lookback) | where ResultType == 0 | project Upn = tolower(UserPrincipalName), TimeGenerated),
      (AADNonInteractiveUserSignInLogs | where TimeGenerated > ago(Lookback) | where ResultType == 0 | project Upn = tolower(UserPrincipalName), TimeGenerated)
    | summarize LastSeen = max(TimeGenerated) by Upn;
Guests
| join kind=leftouter Active on Upn
| extend DaysSilent = iff(isnull(LastSeen), 9999, datetime_diff("day", now(), LastSeen))
| extend HomeDomain = extract(@"_([^_]+)#EXT#", 1, Upn)
| extend HasRole = isnotempty(tostring(AssignedRoles)) and tostring(AssignedRoles) !in ("[]", "null", "")
| project Upn, AccountDisplayName, HomeDomain, IsAccountEnabled, LastSeen, DaysSilent,
          HasRole, AssignedRoles, GroupMembership, AccountObjectId
| sort by HasRole desc, DaysSilent desc

DaysSilent of 9999 means no successful authentication at all in the window. That is the column to read first, and it is deliberately not the same as "invited a long time ago", because invitation records age out of AuditLogs while the account does not.

The Ones With Actual Privilege

Dormancy is the volume problem. Privilege is the urgent one.

Guests can hold Entra directory roles. They can also sit in groups that grant access to things nobody intended, which is usually how it happens: somebody adds the external consultant to a group called Finance Reporting because it was the fastest way to share a file.

// Guests holding a directory role or sitting in a sensitive group
let IdentityLookback = 14d;
IdentityInfo
| where TimeGenerated > ago(IdentityLookback)
| extend Upn = tolower(coalesce(column_ifexists("AccountUPN", ""), column_ifexists("AccountUpn", "")))
| extend MemberType = coalesce(column_ifexists("UserType", ""), column_ifexists("TenantMembershipType", ""))
| where MemberType =~ "Guest"
| where isnotempty(Upn)
| summarize arg_max(TimeGenerated, IsAccountEnabled, AccountDisplayName, AssignedRoles, GroupMembership) by Upn
| extend Roles = tostring(AssignedRoles), Groups = tostring(GroupMembership)
| extend HasDirectoryRole = isnotempty(Roles) and Roles !in ("[]", "null", "")
// Pull the matching group names out, so the analyst sees which group rather than a boolean
| extend SensitiveGroups = extract_all(@"(?i)([^""\[\],]*(?:admin|finance|payroll|legal|exec|board|confiden|security|privileg)[^""\[\],]*)", Groups)
| where HasDirectoryRole or array_length(SensitiveGroups) > 0
| extend HomeDomain = extract(@"_([^_]+)#EXT#", 1, Upn)
| project Upn, AccountDisplayName, HomeDomain, IsAccountEnabled, HasDirectoryRole,
          AssignedRoles, SensitiveGroups
| sort by HasDirectoryRole desc, array_length(SensitiveGroups) desc

The group keyword list is a starting point and it is wrong for your tenant. Read your own group names first and rewrite it, because the words that matter here are the ones your organisation actually uses.

Invitation anomalies are their own rule in the Fixing the Analytic Rule series, so this query stays on standing privilege and hands that axis off rather than scoring it twice.

What You Should Do Next

Run query one and sort by DaysSilent. Anything at 9999 that is still enabled has not authenticated once in thirty days and is a deletion candidate, not a review candidate. Guests are free to re-invite. That asymmetry is the whole argument for being aggressive.

Treat query two as a same-week job. A dormant guest is exposure. A guest holding a directory role is a finding with a name attached to it, and it needs an owner today.

Check who is allowed to invite. In Entra external collaboration settings, the default permits member users, and in some tenants guests themselves, to send invitations. If a guest can invite a guest, one compromised partner account extends your directory on its own. This is a portal setting rather than a query, and it is the highest value five minutes on this page.

Then wire guests into offboarding. The leaver who invited their personal address is invisible only because offboarding checks internal accounts. Add a guest review keyed on the departing person as inviter and that closes permanently.

Class dismissed.

Consent Preferences