
Entra ID Directory Enumeration: The Attack Map
A default member in Entra ID can enumerate the whole directory: every user, group, service principal and role assignment. The controls that narrow it.
In August 2026 a criminal seller advertised around 3.6 million employee records taken from corporate Entra ID tenants, across nine large organisations. The datasets are reported to hold names, corporate email addresses, phone numbers, employee IDs, job titles and department information, which is to say the ordinary contents of a directory.
What makes it worth your attention is what it was not. No Azure vulnerability and no zero day have been found. Researchers attribute the access to stolen credentials and infostealer infections, which means ordinary valid logins reading exactly what those logins were always permitted to read. The claims come from a criminal seller, and the scale may not survive scrutiny. The mechanism will, because it needs nothing your tenant does not already allow.
Earlier this year I published a post about the Graph API endpoints that matter to architects. It walks through tenant discovery, identity and access auditing, privileged role review and licensing analysis. It is, read uncharitably, a reconnaissance guide. The same calls I use to produce a tenant assessment for a client produce a complete map of that client's directory for anyone else who can make them.
That is not a problem with the post. It is the point I want to make here. The difference between an architect running an assessment and an attacker running reconnaissance is not the API, the permissions, or the tooling. It is who is holding the credential, and whether anyone is looking.
This post works through what a default member can actually see, the two separate routes to a bulk export and why they need different controls, and the five settings that narrow them. Then it spends longer than all of that on detection, because the detections in circulation do not fire on a real export and it took a live tenant to find out why.
How to read this. Sections 1 to 5 are the controls. They are short, they are current, and you can act on them this week. Section 6 is longer than the rest of the post combined, because it is the part nobody publishes: the Graph activity detections in circulation, mine included, do not fire on a real directory export, and I have the row counts. If you only want the controls, stop at section 5.
Read access is not low-risk, it is just quiet
Ask most architects to rank their Entra risks and directory read access lands near the bottom. Write access gets Conditional Access, PIM and approval workflows. Read access gets a shrug, because it is only the address book.
It is not only the address book. A default member user in a stock Entra tenant can enumerate every user object, every group and its membership, every service principal, every registered application, every device, and every directory role assignment. Microsoft documents this plainly in the default user permissions reference: member users can "read all directory information (with a few exceptions)".
Work through what that actually yields.
- Every user object gives you the naming convention, the department structure, the job titles, and whatever your HR sync has decided to write into extension attributes.
- Every group and its membership gives you which people have access to which systems, and which groups are used for licence assignment rather than access.
- Every directory role assignment gives you your Global Administrators, your Privileged Role Administrators and your Security Administrators. A target list, ranked by value.
- Every service principal and application registration gives you the non-human identities, their permission grants, and by inference which of them nobody is monitoring.
That is not a directory listing. It is a complete map of who holds privilege, which accounts are non-human, how your naming conventions work, and which service accounts are likely to be unwatched. It is precisely the reconnaissance package needed to build a convincing help-desk social engineering script, or a targeted spear-phishing run against however many names come back from /directoryRoles, which in most tenants is a short list.
One set of valid credentials on an unmanaged device is enough to pull all of it. So the question is not how to stop the export. It is how to make ordinary credentials insufficient, narrow what they can reach, and see it when it happens.
If you read nothing else, do two things: require a compliant device on all resources, and stop users consenting to applications themselves. They defend different routes and neither covers for the other. The rest of this post is why, and what comes after.
There are two doors, not one
This is the part most hardening write-ups get wrong.
There are two routes to a bulk directory read, and they are defended by completely different mechanisms.
The first is credential theft. An attacker obtains a user's credentials, from an infostealer, a phishing page or a token replay, and calls Graph as that user. The token is a delegated token, issued to an interactive user session, and it is therefore subject to Conditional Access.
The second is consent. A user is persuaded to consent to a plausible-looking application that requests User.Read.All or Directory.Read.All. This is consent phishing, also called an illicit consent grant, and it needs no credential theft at all. Once consented, the application holds its own permission grant and calls Graph on its own behalf.
The blanket version of this is wrong, so be precise. Conditional Access for workload identities does exist. Its documented scope covers single-tenant service principals registered in your own tenant, it requires a Workload ID Premium licence, and it explicitly excludes multitenant and third-party applications along with managed identities. A consented third-party application is precisely that excluded category. So for this route your device compliance policy does not see the traffic and your named locations do not see it, because the application is not a user and never signs in like one.
So when you read the next section and see me recommend a device compliance policy as the highest-leverage control, understand what that leverage covers. It raises the cost of the first door considerably and does nothing whatsoever about the second. Anyone who tells you a device compliance policy stops directory enumeration is describing half the problem, and as the next section admits, not even all of that half.
1. Make stolen credentials useless on their own
This is not a directory setting, and it does the most to raise the cost of the credential theft path. Credentials harvested from a browser or an infostealer are only valuable if they work from the attacker's machine.
Phishing-resistant authentication. Passkeys or certificate-based authentication as the primary method. This is happening to you anyway: from 1 September 2026, users enabled for SMS or voice are automatically enabled for passkeys and nudged to register at their next MFA sign-in. The date that should be in your project plan is 1 February 2027, when Microsoft-provided SMS and voice retire outright and the passkey registration prompt becomes blocking for anyone whose only method is a phone number. Microsoft's wording is unambiguous: there is no opt out from the February behaviour. Treat the intervening months as a security programme rather than a rollout chore.
Require a compliant or hybrid-joined device on all resources. This is the control that raises the cost of interactive directory export the most, because Graph tokens are issued for the resource the client requests. A Conditional Access policy requiring device compliance across all resources stops a token being issued to a session running on an unmanaged device, which means an attacker holding your user's password cannot simply sign in from their own machine. If you are building this from scratch, the baseline policy set in my Conditional Access post has the ordering and the exclusions worked out, including the compliance circular dependency that catches everyone the first time.
Be precise about what it does not do, because this is where most write-ups overreach and I nearly did too. Device compliance gates token issuance, not token use. It does not re-evaluate device state against an already-issued bearer token on each Graph call. Microsoft's own Continuous Access Evaluation documentation states the pre-CAE baseline plainly: clients replayed the cached access token for as long as it had not expired. So a token lifted by an infostealer from a legitimate, compliant, fully managed laptop still works from the attacker's machine. The policy stops the attacker's device getting a token. It does not stop your device's token leaving the building.
Token protection and Continuous Access Evaluation. Token protection binds a refresh token to the device it was issued to, and is therefore exactly the control that would close the gap I have just described. Check its supported resources before you count on it. Today they are Exchange Online, SharePoint Online and Teams for native apps generally, plus Azure Virtual Desktop and Windows 365 on Windows specifically, with browser coverage still in preview and scoped to Azure Resource Manager alone. Microsoft Graph is not on that list. For the threat this post is about, bulk reads against Graph, token protection currently contributes nothing. Deploy it for the workloads it does cover and do not carry the assumption across to this one. Continuous Access Evaluation is the more broadly applicable of the pair, shortening the window between a revocation event and it taking effect, which matters here precisely because a stolen token is otherwise good until it expires.
2. Close the consent phishing path
The second route needs no credential theft at all, and none of the controls above touch it.
A user consents to an application requesting User.Read.All or Directory.Read.All. The consent screen is the entire security boundary, and it is being read by someone who wants to get on with their day. Once granted, the application exfiltrates on their behalf, from infrastructure you have never seen, using a token your Conditional Access policies never evaluate.
Start by finding out where you stand:
Connect-MgGraph -Scopes 'Policy.Read.All'
$policy = Get-MgPolicyAuthorizationPolicy
$policy.DefaultUserRolePermissions | Format-List
$policy.GuestUserRoleIdThe value you care about is PermissionGrantPoliciesAssigned. Microsoft ships two built-in policies for user consent, documented here:
microsoft-user-default-low allows user consent only for applications from verified publishers or registered in your own tenant, and only for permissions you have explicitly classified as low impact. Note the dependency: if you have not classified any permissions, this setting grants nothing, which is a safe failure but confuses people who expect it to work out of the box.
microsoft-user-default-legacy allows all users to consent to any permission that does not require admin consent, for any application. This should not exist in a production tenant. If your tenant predates the current defaults, check, because you probably have it.
Disabling user consent entirely is defensible provided you stand up the admin consent workflow alongside it, so the friction is routed rather than absorbed. Consent requests that have nowhere to go do not disappear, they become shadow IT: users get the same applications consented anyway, just through a personal account or an unmanaged guest tenant your admins never see.
The command that writes this setting back is in the next section rather than here, because the API takes the whole authorization policy object at once and there is no sense showing you the same call twice.
3. Tighten the default user role, but not the way you think
Three things happen in the call below: the application and tenant creation defaults are closed, the consent policy chosen in the previous section is written back, and the guest role is locked down. They travel together because the authorization policy is written as one object.
Connect-MgGraph -Scopes 'Policy.ReadWrite.Authorization'
$params = @{
DefaultUserRolePermissions = @{
AllowedToCreateApps = $false
AllowedToCreateSecurityGroups = $false
AllowedToCreateTenants = $false
AllowedToReadBitlockerKeysForOwnedDevice = $false
PermissionGrantPoliciesAssigned = @(
'managePermissionGrantsForSelf.microsoft-user-default-low'
)
}
GuestUserRoleId = '2af84b1e-32c8-42b7-82bc-daa82404023b' # Restricted Guest User
}
Update-MgPolicyAuthorizationPolicy -BodyParameter $params
PermissionGrantPoliciesAssignedis replaced wholesale, not merged. If your tenant has anymanagePermissionGrantsForOwnedResource.*entries, for example the developer consent policy that lets application owners manage consent for apps they own, the block above will silently remove them. Read the current collection first and write back the full set. Microsoft's own documentation flags this, and it is the sort of change that breaks a developer workflow three weeks later with no obvious cause.
The guest setting is the quiet win, though not for the reason it is usually sold. This is still door one, credential theft, just against a guest identity that starts with more standing permission than it needs.
Default guest users already cannot enumerate the directory. Microsoft's documentation is explicit: "guest users can't enumerate the list of all users, groups, and other directory objects." Anyone telling you that switching to Restricted Guest User stops guests listing your directory is describing a control that was already in place.
What the restricted role actually removes is the residual targeted read that ordinary guests retain. A default guest can read the display name, email address, sign-in name, photo, user principal name and user type of any other user. They can read manager and direct report relationships, which reconstructs your org chart one query at a time. They can search for a user by object ID. And they can read the properties of any non-hidden group, including its active and eligible membership and its ownership, in Microsoft's words "even nonjoined groups".
So a default guest cannot ask for a list of your groups, but can read any group they can name. Given a handful of names from an email thread and a predictable naming convention, that distinction gets thin quickly. Restricted Guest User closes it: the guest is left with their own properties, their own mobile number, and the object ID of groups they have actually joined.
One thing it does not close. Both default and restricted guests retain the ability to read properties of registered and enterprise applications, and to list permissions granted to applications. The guest role setting does nothing about the application half of the map. If your concern is service principal reconnaissance specifically, this control is not your answer.
The trap:
allowedToReadOtherUserslooks like the obvious kill switch. Microsoft's Graph reference says, in capitals, DO NOT SET THIS VALUE TOfalse. It breaks people pickers, Teams, Outlook address resolution and assorted first-party services in ways that are difficult to diagnose and difficult to unwind. If you find a blog post recommending it as a hardening step, close the tab. The directory is designed to be readable by members. The answer is controlling who becomes an authenticated member and from where, not amputating the read.
4. Reduce what is worth stealing
Every control so far narrows who can read the directory. This one changes what is there to read, which makes it the only section that helps regardless of which door the attacker came through.
It is also the section that gets filed as tidy-up. An application registration with an expired secret and no owner is not tidy-up. It is an unowned identity with permission grants attached, and it appears in the same export as everything else.
Audit what your HR sync writes. Personal mobile numbers, home addresses, payroll identifiers, cost centres and manager chains end up in extension attributes routinely, written by a joiner-mover-leaver integration that nobody has reviewed since it was built. None of it needs to be readable by every member of the tenant. Go and look at what is actually there:
Connect-MgGraph -Scopes 'User.Read.All'
# What is populated on a representative sample, including on-premises extension attributes
$props = 'displayName','jobTitle','department','officeLocation','mobilePhone',
'employeeId','employeeType','onPremisesExtensionAttributes','companyName','streetAddress'
Get-MgUser -Top 50 -Property $props |
Select-Object DisplayName, JobTitle, Department, MobilePhone, EmployeeId,
@{n='Ext';e={ ($_.OnPremisesExtensionAttributes.PSObject.Properties |
Where-Object { $_.Name -like 'ExtensionAttribute*' -and $_.Value } |
ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ' }} |
Format-Table -AutoSizeMost tenants I run this against surface at least one field nobody knew was syncing. Payroll number and cost centre in extensionAttribute3 and extensionAttribute7 is close to a cliche.
Kill stale objects. Dormant accounts, orphaned service principals and legacy application registrations inflate the map, and each one is a candidate for takeover rather than merely a line in a report. My tenant health post has the detection queries for ownerless groups, dormant sites and expired credentials, and this is a second reason to run them.
One caution before you delete anything you do not recognise. Registering an Azure resource provider adds a service principal to your directory, and Microsoft's own guidance says plainly that doing so "can cause apps that you don't recognize to appear in your Microsoft Entra tenant". So a proportion of the unfamiliar service principals in your first enumeration are load-bearing Azure plumbing rather than debris, and nobody in your organisation will remember installing them, because nobody did. Unrecognised is not the same as orphaned, and this is the bullet in this section with the most potential to break something.
Review naming conventions. svc-sql-prod-backup tells an attacker precisely what to phish for, what it probably has access to, and that nobody is watching it. Obscure names are not a security control and I will not pretend otherwise. But there is a difference between a name that describes a function and one that publishes an attack path. Fix it in new builds. Renaming the existing estate is rarely worth the breakage.
Use custom security attributes for anything genuinely sensitive. They have their own RBAC model and, unlike extension attributes, they are not readable by anyone who can read the object. Microsoft's own comparison is blunt about the difference: extension data is readable by anyone with permission to read the object, while custom security attributes are restricted through a separate set of permissions and role-based access control. Even Global Administrator has no access by default. If you have data that genuinely has to live in the directory but must not be tenant-readable, this is the supported answer and there is no other one.
5. Protect the privileged tier, with realistic expectations
Use separate cloud-only admin accounts, PIM for every eligible role, and mandatory phishing-resistant authentication. Exclude admin accounts from anything that softens Conditional Access rather than strengthens it.
Restricted Management Administrative Units are worth deploying for break-glass and Tier 0 accounts. They are also routinely oversold.
They block management operations by administrators not scoped to the unit. Even a Global Administrator cannot reset the password of an account inside one without first assigning themselves a role scoped to that unit, which is an auditable event. That is genuinely valuable, and it is the answer to the helpdesk-role-resets-the-CEO's-password problem.
They do not restrict read. Microsoft's own documentation table lists "Read standard properties like user principal name, user photo" in the Allowed column. The account is still enumerable, still visible, still shows up in a directory dump with its role assignments attached. A Restricted Management Administrative Unit stops an attacker with a helpdesk role from resetting your break-glass password. It does not hide the account's existence, and any guidance suggesting otherwise is wrong.
Deploy them for the former. Do not claim the latter. Licensing, since it is usually the next question, is Entra ID P1 for each Administrative Unit administrator and Entra ID Free for the members.
This is the clearest admission in the post that the read itself will not be stopped, which makes it the bridge to the next section. The posture it assumes is the one everything from here on depends on: the enumeration is going to succeed, so the job is making sure that what it returns cannot be turned into control of the tenant.
6. Assume it will happen anyway, and instrument for it
Everything above narrows the aperture. None of it closes it, because the directory is designed to be readable and a determined attacker with a valid identity will read some of it. So the last control is the one that tells you it happened.
Turning the logs on
Microsoft Graph activity logs are the detection surface, and they are off by default. The prerequisites are an Entra ID P1 or P2 tenant licence and an Azure subscription with somewhere to send the data. You enable collection through Azure Monitor diagnostic settings, targeting a Log Analytics workspace, an Event Hub, or a storage account.
Note the word tenant in that licence requirement. It is a tenant licence, not a per-user assignment, so a tenant holding P1 with every seat unassigned still qualifies. Getting it the wrong way round produces an empty table rather than an error, which is the failure mode this entire section is about.
Two things about that setup caught me out while trying to verify the queries below.
Neither the wait nor the silence is documented
The published latency figure does not describe what you are doing. Microsoft says events are delivered within about thirty minutes in most regions and up to two hours in less common cases. Read it precisely: that describes delivery of events to an already configured destination. Nothing is published about how long a newly created diagnostic setting takes to begin emitting at all. Those are different intervals, and only the second one has a number attached.
Here is one measurement, since nobody seems to publish any. In a test tenant, first enablement of the Graph activity logs category took eleven hours and forty-two minutes, from registering the Azure resource provider to the first captured event. Traffic ran every three minutes throughout, so the moment collection began is bracketed to within three minutes rather than estimated. Sign-in categories in the same tenant, into the same workspace, behind the same provider, started delivering in about two hours. One observation in one tenant is a data point rather than a distribution, and you should not plan against my number. You should plan against the fact that the documented figure was out by an order of magnitude and nothing warned me.
Two details make this worse than it sounds. The provisioning clock is attached to the tenant and the category, not to your diagnostic setting: two settings in different regions, created five and a half hours apart, began capturing at the same instant. So deleting and recreating the setting when nothing arrives, which is the first thing anybody tries, accomplishes nothing at all. And there is no backfill. Everything generated during the wait is gone permanently, so the window in which you believed you had monitoring is a hole in your evidence that no amount of later fixing will fill.
That last point is a security consequence rather than an operational one. If you ever need to prove an export did not happen during that period, you will query an empty table and get nothing, and the nothing will look exactly like a clean result.
Nothing reports whether ingestion works. A Log Analytics workspace will report its provisioning state as succeeded, accept a diagnostic setting without complaint, return that setting correctly on read-back, and have received nothing at all. There is no aggregate signal anywhere that answers the question you actually care about, which is whether data is arriving. So confirm rows exist before you write or trust a single query, and never mistake an empty table for a quiet tenant.
Confirm the count has stopped moving, too, rather than merely that rows are present. Send a known number of requests, count them back, and keep counting until the number stays put.
Here is why that matters, measured across 310 rows in the same tenant. Median delivery lag was four minutes and forty-eight seconds. The ninety-fifth percentile was ten minutes. The maximum was thirty-four minutes and forty-two seconds.
Sit with that shape, because it is precisely the worst one for anybody trying to check their own collection. The median is fast enough that a quick look appears to confirm everything works, so verifying properly feels like wasted effort. The tail is long enough that an early count is wrong, but only occasionally, and only on the last few rows. I lost two hours to a thirty minute census window that was short by under five minutes, and produced a confident capture-loss finding that evaporated on a longer read. The practical form is a single number: wait an hour before you treat a count as final, not thirty minutes and certainly not five.
A count read too early is not a measurement, it is a checkpoint, and treating one as the other is how you conclude that collection is lossy when it is only slow.
One tempting cross-check does not work, incidentally. Pointing a second diagnostic setting at a second workspace, in a different region, gives you a duplicate rather than an independent view. Both of mine held exactly the same row count with the same earliest event, because they are one stream delivered twice. If the collection is short, both copies are short identically, so neither can validate the other. Counting known traffic back is the only check that tells you anything.
What it costs, and why you baseline first
One more operational number, and this one is about money rather than time. Ingestion is billable: Microsoft's own estimate is around 15 GiB per month for a thousand-user tenant, scaling to roughly 1,200 GiB for a hundred thousand users. At the larger end that is a real line item, and workspace transformations are worth investigating before you turn it on estate-wide.
Be careful with the cheaper table plans, though, because the obvious cost lever and the detections below pull against each other. This table supports the Basic and Auxiliary plans, and both are commonly chosen for it precisely because it is high volume. Both also restrict the KQL you can run against the table and change retention. If you move this table to Basic to control spend and then find the aggregation in the queries below will not run, that is the trade you made rather than a fault in the query.
Baseline before you alert. This is the step everyone skips and it is the reason most of these detections end up muted. Your own reporting jobs, your HR sync, your Intune connector, your licensing automation and your identity governance tooling all make exactly the kind of broad, paged, high-volume directory reads that any enumeration detection is looking for. If you deploy a threshold rule on day one, it will fire on your own infrastructure, someone will add an exclusion in a hurry, and the exclusion will be broader than it needed to be.
Spend a fortnight watching first. Identify every application and service principal that legitimately sweeps the directory, understand its normal volume and cadence, and only then decide what abnormal looks like.
One caveat on that fortnight, which follows directly from the prerequisites above. A fortnight of no results is not a baseline until you have proved data arrives at all. Otherwise you spend two weeks measuring nothing, conclude your tenant is quiet, and have built your thresholds on a pipeline that was never delivering.
// Baseline: who reads the directory in bulk, and how much, over 14 days
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(14d)
// One row per request first: this table repeats them, and a summed byte
// count over raw rows is inflated by however often it does.
| summarize
ResponseSizeBytes = take_any(ResponseSizeBytes),
TimeGenerated = take_any(TimeGenerated),
UserAgent = take_any(UserAgent)
by RequestId, AppId, ServicePrincipalId
| summarize
Requests = dcount(RequestId),
Bytes = sum(ResponseSizeBytes),
Days = dcount(bin(TimeGenerated, 1d)),
Agents = make_set(UserAgent, 5)
by AppId, ServicePrincipalId
| order by Requests descAnything appearing on most of the fourteen days with a stable volume is infrastructure. Anything appearing once, in volume, is the thing you are looking for.
Two things in that query are deliberate and will look wrong if you have written one of these before. There is no filter on request method, because the /$batch envelope that can carry an entire export is a POST and a method filter throws it away; section 6 gets to that the hard way. And the first summarize exists only to collapse the table to one row per request before anything is summed, because this table repeats rows and a byte total taken over the raw rows is inflated by however often it does. Both of those are conclusions from the rest of this section, applied in advance.
The query I nearly published
With the baseline established, the obvious detection is a broad sweep by a single identity. Here is the query I wrote, and very nearly shipped, before anyone ran it.
// Broad directory sweep by a single identity. This does not work.
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(1d)
| where RequestMethod == "GET"
| where RequestUri has_any (
"/users", "/groups", "/servicePrincipals", "/applications",
"/devices", "/directoryRoles", "/administrativeUnits")
| summarize
Requests = count(),
DistinctPaths = dcount(tostring(parse_url(RequestUri).Path)),
Bytes = sum(ResponseSizeBytes),
SourceIPs = make_set(IPAddress, 10)
by UserId, AppId, bin(TimeGenerated, 1h)
| where Requests > 500 or DistinctPaths > 5
| order by Requests descIt parses. Every column exists with the right type. It returns no error. And it does not detect a directory export.
Naming is about to matter, because this is not the only query in play. Call the one above the breadth query: it is looking for a single identity touching many kinds of object, and its two thresholds are DistinctPaths > 5 for the number of object types and Requests > 500 for sheer volume. Alongside it I wrote a second query, the paging query, which is the subject of a later part of this section and which opens like this:
// The paging query. Also does not work, for different reasons.
MicrosoftGraphActivityLogs
| where RequestUri has_any ("$top=999", "$skiptoken")
| summarize Pages = count() by UserId, AppId, bin(TimeGenerated, 1h)
| where Pages > 20Two queries, then, and later a corrected version of each, which is four things to keep straight. I will name them each time rather than say "the query".
Two separate things are wrong with the breadth query, and both are about it not matching what I assumed the data looked like.
Pagination hides in the query string. Microsoft Graph paginates by varying the query string, not the path: the nextLink for /v1.0/users?$top=999 differs from the request before it only in its $skiptoken. Meanwhile parse_url() returns the path with the query string stripped out and handed back separately, and its documentation is explicit that it "parses an absolute URL string".
So every request in a paged export produces an identical path. Do the arithmetic on a twenty thousand user tenant pulled at 999 records per page. That is twenty-one requests, so Requests is 21 and does not clear 500, and DistinctPaths is 1 because the path never changed, so it does not clear 5 either. The query returns nothing. Now consider an application reading six individual users by object ID, about as ordinary as directory traffic gets: six distinct paths, threshold cleared, alert raised.
That is the arithmetic. Here is the measurement, because I ran the query above unmodified against three scenarios in a real tenant, each in isolation.
| Scenario | Requests | DistinctPaths | Bytes moved | Fires? |
|---|---|---|---|---|
| Broad sweep across seven object types | 21 | 7 | 1.12 MB | Yes |
| Complete export of one collection, 27 pages | 28 | 2 | 2.36 MB | No |
| Thirteen individual objects read by ID | 14 | 13 | 16 KB | Yes |
The export moved two and a third megabytes and did not fire. The thirteen object reads moved sixteen kilobytes and did. The scenario that took a hundred and forty-three times more data is the one the query missed.
That export was not a contrived case designed to embarrass the query. It was the complete set of every service principal in the tenant, five hundred and thirty-four of them, pulled by a single identity in two and a half minutes.
The query catches the noisy attacker, misses the efficient one, and fires on entirely ordinary access. A sweep touching all seven object types does produce seven distinct paths and does fire, so it is not useless. But the single-resource bulk dump, which is both the likeliest case and the one that actually costs you the directory, is precisely the case that scores lowest on the metric.
DistinctPaths was never measuring breadth. Compare it against the number of distinct resource types on the same rows and the two agree on the sweep, at seven and seven, and agree on the export, at two and two. On the object reads they diverge by a factor of thirteen: thirteen distinct paths, one resource type, because the thirteen objects all lived in the same collection. The query scored a single-collection read as broader than a seven-endpoint sweep.
It was counting distinct objects touched, which is a number a competent export minimises by construction.
Term matching is not substring matching. The paging query above opens on | where RequestUri has_any ("$top=999", "$skiptoken"), and whether that line matches anything depends on a storage detail Microsoft's schema documentation does not state. You cannot reason your way to the answer. I know, because I tried and got it wrong.
The answer, once I actually looked at a row: the URI is stored absolute, scheme and host included, and the query string is kept raw, unencoded and untruncated, dollar signs intact and delta tokens of two thousand characters preserved whole. So has "skiptoken" does match and parse_url() does return a usable path. Both of the things I could not determine happened to fall the convenient way. Worth knowing, and worth knowing that nothing in the documentation would have told you, because the next person may be less lucky and the failure is silent in either direction.
Kusto's string operator reference defines a term precisely: "each string value is broken into maximal sequences of alphanumeric characters, and each of those sequences is made into a term." Its own worked example is the whole mechanism in one line: "KustoExplorerQueryRun" has "Explorer" returns false.
Apply that to a percent-encoded URI. The natural assumption, and mine, is that %24top=999 is fine because the punctuation drops out, leaving 24, top and 999. It does not. After the %, the characters 2, 4, t, o and p are all alphanumeric and contiguous, so they form one term: 24top. The delimiter does not merely fail to match, it fuses with the text beside it and destroys the terms on both sides. has "$top=999" is true against ?$top=999 and false against ?%24top=999, and mentally deleting the punctuation gives you the wrong answer every time.
Two consequences worth carrying away. The leading slash in every "/users" is decorative, since has "/users" and has "users" are the same query. And the $top=999 clause looks redundant sitting beside $skiptoken, because Graph pages with a skiptoken whatever page size the client asked for, so the skiptoken appears to be doing all the work while $top=999 only adds a way to fail.
I removed it on exactly that reasoning. It turned out to be the worst decision in this post, and I will come back to why, because the reason did not surface until the queries met traffic I had not thought to generate.
That leaves a gap I did not see until a real row showed it to me. Paging is not the only way to read a directory in full. The delta query endpoints, /users/delta and friends, enumerate the entire collection and then hand you a $deltatoken to resume from. That is a different token in a different parameter, so a filter keyed on $skiptoken alone watches a complete directory enumeration go past and says nothing.
I found this because one turned up uninvited. Watching a four user test tenant at nine on a Tuesday morning, with nobody signed in and no application anybody had deliberately installed, a first-party Microsoft service ran a delta sync against /users. It is a GET, it is against /users, it matches the first query's has_any perfectly, and it is entirely legitimate. That is the baselining problem in a single row: the first thing you will see is traffic you do not recognise and must not alert on.
It also carried a $deltatoken about two thousand characters long. Request URIs are stored whole, so a tenant with real delta traffic is writing multi-kilobyte URIs on every request, which is worth knowing before you take Microsoft's fifteen gibibytes per thousand users as a planning figure.
Metrics that measure the wrong thing
The first query's other threshold arm, Requests > 500, fails for a different reason than its path count, and the reason generalises.
For a paginated sweep, request count simply is page count, and page count is ceil(N / $top) where $top is chosen by the client. An attacker maximising page size to get the export over with reduces the exact number the detection is watching, and does it for efficiency rather than evasion. That is worse than an evadable detection. It degrades by default, against an attacker who is not even trying.
Response volume holds up better, and the obvious objection is correct: bytes can be reduced too. An attacker requesting $select=id,userPrincipalName takes your whole directory at a fraction of the volume. The difference is that reducing volume costs them something and reducing page count costs them nothing. Shrinking pages is free and helps them; shrinking bytes means leaving data behind. That property, rather than any particular column, is what makes a metric worth building on: the ones that hold up are the ones an attacker can only improve by taking less of what they came for.
Bytes per page is a useful supporting dimension, since the large page size that hides someone from a page count makes their bytes-per-page conspicuous. It narrows the gap rather than closing it, because the two evasions pull opposite ways: a large $top raises the ratio and a trimmed $select lowers it, so an attacker doing both lands back in an unremarkable range.
And there is a way to defeat the volume metric outright, which I would not have believed without seeing the rows. Batch a set of requests through /$batch and the sub-requests are logged beautifully: one row each, correct inner URI, GET rather than POST, everything a detection could want. Every one of those rows carries ResponseSizeBytes of zero. The entire payload, a hundred and seventeen kilobytes in the case I looked at, is attributed to the single POST row for the batch envelope.
So an attacker who batches their enumeration takes the whole directory, has every inner request faithfully recorded with the right path, and contributes nothing whatsoever to a volume threshold. That is worse than the page-count problem, because it is not a matter of degree. The metric goes to zero while the theft goes to completion.
It also breaks the breadth query twice over, by two different routes. As written, its RequestMethod == "GET" filter shows you the five inner rows and sums their zero bytes, while the row holding the payload is a POST and is excluded before anything else happens. Drop the method filter, as the correction below suggests, and you reach the envelope row, only to lose it at the next line: the resource list names seven collections and batch is not among them, so on the query as printed the envelope is discarded on a plain miss. Widen that list to match any resource, which is the obvious repair, and you meet the second version of the problem, because a pattern anchored on a letter will not match $batch with its leading dollar. Same outcome, three different mechanisms, and I wrote my way into all of them.
That qualifies the claim I made a few paragraphs ago. Volume is the better metric because an attacker can only reduce it by taking less, and that remains true of the data they receive. It is not true of the data your table records, and batching is the gap between those two things.
There is a subtler version of the same problem, and it defeats every query in this post, including the corrected ones.
count() counts rows. In this table a row is not a request. A cohort of twenty-seven requests came back as twenty-nine rows, which is the sort of impossible number that forces you to look properly: one request had been recorded three times, three of the rows carrying an identical timestamp to the microsecond. Across the whole capture, three point two percent of rows were repeats of a request already recorded.
So any threshold built on count() is inflated by a duplication rate nobody publishes and which you have no particular reason to suspect. dcount(RequestId) counts requests. count() counts log entries. On a dashboard the difference looks exactly like ordinary variance, which is why it survives.
Microsoft's own example queries are instructive here, though not because they are consistent. Two of them count distinct request identifiers, one using count_distinct(RequestId) and one using dcount(RequestId), and the first names its output UniqueRequests, which is not a phrase anybody reaches for unless they have met non-unique ones. The other two use a plain count(). Nothing in the surrounding prose explains the difference, or mentions anywhere that a request can appear more than once. Write your own query from the schema, as I did, and you will reach for count() and be quietly wrong.
Then there are the numbers themselves. Five hundred requests and five distinct paths in the breadth query, twenty pages in the paging query. Where did they come from? Nowhere. I chose them because they looked like the sort of number a threshold has, and essentially every KQL detection published on a blog, this one included until I checked it, carries thresholds the author never calibrated against a real workload. Calibrating requires a populated workspace, and most people writing the post do not have one to hand.
That is why the baselining fortnight is not optional preamble. Your thresholds should come from your own distribution, sitting some distance above your noisiest legitimate consumer, and they will not resemble mine. Any threshold you copy from a blog post, this one included, is a placeholder until you have replaced it with a number from your own tenant.
The same family of problem shows up in how the queries bin time, too. Both aggregate into fixed bins, hourly and daily. An export running from 13:58 to 14:02 splits across two hourly buckets, roughly half its requests and half its volume in each, and neither half clears a threshold the whole event would have cleared comfortably. The query is valid, raises no error, and silently fails to fire on an event that satisfies its own stated intent.
The mitigation costs nothing and does not require rewriting the query into something unreadable. In a Sentinel analytics rule, set the query period longer than the query frequency: run every fifteen minutes across a one hour lookback, and any event short enough to straddle a boundary falls entirely inside at least one evaluation. You pay in duplicate alerts, which is a far better failure mode than silent misses. Querying by hand or from a workbook, the honest answer is that the bin is a real limitation, and you should widen it when investigating rather than trust it when monitoring.
All three fail the same way. The query is valid, the workspace is green, the table returns rows, and none of that tells you the detection would fire on the thing it was written for. Every prerequisite before them fails the same way too.
Why there is no corrected query here
You have probably noticed what is missing. Having taken two queries apart, the obvious next move is to hand you fixed ones. I am not going to, and the reason is the most useful thing in this post.
The corrections themselves are not a mystery, and they are all in the prose above:
- Anchor on the version segment with
extract()rather thanparse_url(), so the query works whether the URI is stored absolute or relative rather than depending on which. - Match on
skiptoken, keep$topbeside it rather than dropping it as I did, and adddeltatokenas well, so that neither a delta enumeration nor a single-request export is invisible. - Count with
dcount(RequestId)rather thancount(), because a row in this table is not a request. - Do not filter on request method, and make sure your resource pattern tolerates a leading dollar, or the
/$batchenvelope that carries all the bytes will be thrown away. - Count response volume rather than pages, because volume is the thing an attacker can only reduce by taking less.
- Group by identity and put source address in a
make_setrather than the grouping key, so a rotating address cannot fragment one actor into several. - Bin to something wider than the event you are hunting.
Getting to a point where any of that could be run against real data took very much longer than writing it.
The attempt failed in exactly the way this section has been describing: hours spent ruling out the workspace, the region, the pipeline, the licence, the permissions and the category name one at a time, before finding a genuine defect, an Azure resource provider that had never been registered and that ARM had been perfectly happy to accept diagnostic settings against for five hours while silently discarding everything behind them. Then the same eleven hours and forty-two minutes measured earlier, and the first row appeared.
That interval is the whole argument. During it, a competent engineer with a method, instrumentation and a deliberate intention to verify could not distinguish a broken configuration from a working one, because both produce an empty table and nothing anywhere reports the difference. At one point the evidence pointed hard at the Graph activity category being faulty: seven causes excluded, a sibling category delivering in ninety seconds, that category silent for hours. I nearly called it. What stopped me was writing down what I had not excluded, and admitting there is no threshold at which slow becomes broken. If you take one working habit from this post, take that one: record the exclusion you cannot make, and do not let a leaning become a finding.
I eventually ran the queries, which is where the measured numbers earlier in this section came from. Two things it showed are worth stating plainly. One looked, at first, like good news for the original design. The other is why there is still no corrected block here.
The good news first, and then why it evaporates. Run against that bulk export, the paging query fires: twenty-six pages, comfortably past its threshold of twenty, in both the original and my corrected version. So catching breadth with one query and paging with another looks like a sound division of labour, and the pair looks less broken than the breadth query alone.
Then you notice why the export paged twenty-six times. It was pulled at a small page size. The collection it hit, service principals, caps at a hundred objects per page no matter what the client asks for, and there were five hundred and thirty-four of them, so an attacker pulling the lot efficiently makes six requests, only five of which carry a skiptoken at all. Five does not clear a threshold of twenty. The paging query fired only because the test happened to page inefficiently, and an attacker exporting your directory has no reason to. So the paging query catches the one collection that is forced to page, but only against someone being clumsy about it.
That is the same failure the rest of this section keeps finding, committed here by me: a favourable result that is an artifact of the test rather than a property of the attack. The two-query design is not vindicated. What is true is narrower, that the paging query has a smaller window than its single passing run suggested.
The other one is that my corrections are partial rather than complete. Run against the same scenarios, the corrected first query fixes the false positive on ordinary object reads, and fixes the batch problem so the bytes are counted. It still misses the bulk export entirely. A repair that removes two defects and leaves the headline one intact is not a repair, and presenting it as a corrected query with a tidy block of KQL would imply a completeness it does not have.
The export that is one request
Everything above assumes a bulk export is a big, noisy thing: many requests, many pages, something to count. Go and check how many objects are actually in your directory.
$top on directory collections is documented to reach 999. So an organisation with fewer than a thousand users can, in principle, have its entire user list returned in one HTTP request, with no paging, no skiptoken and nothing to accumulate.
Be careful about what that rests on, because it is less than it sounds. In the test tenant four of the five collections did return complete in a single request, but each held only a handful of objects, which shows they fit inside one page, not that one page stretches to 999. The single collection with more than a hundred objects, service principals, capped at a hundred per page whatever the client asked for. So what I actually measured is that a large directory collection refuses a large page; that /users and the rest honour 999 is Microsoft's documentation, not my observation. If /users in your tenant also capped at a hundred, a full export would be several pages rather than one, and the paging query might catch it. Confirm it against your own directory rather than trusting the shape of mine.
Here is that request, and it is the whole user directory:
GET https://graph.microsoft.com/v1.0/users?$top=999
200, 1,332 bytes, one row in the table
I ran all four queries against it in isolation. The original breadth query sees one request and one path, and does not fire. The corrected breadth query sees one request, one resource and a small byte count, and does not fire. The original paging query matches the row, counts one page, and does not fire. And my corrected paging query does not even see it: I dropped the $top clause, a single request carries no skiptoken, so the row is filtered out before any threshold is reached at all.
That is the correction I promised earlier, and it is the most useful mistake in this post. I removed the $top=999 clause because it looked redundant beside a better signal. It was redundant against every scenario I had thought to test, all of which paged. It is the only thing in either query that touches a bulk export which does not page, and a bulk export which does not page is the ordinary case for most collections in most organisations. I made the query strictly worse against the single most serious scenario by removing its only point of contact with it, and I did it while believing I was tidying up.
The wider point is not about my clause. It is that the two queries between them cover multi-type sweeps and exports large enough to page heavily, and cover neither the single-request export nor the efficiently paged one. That is a gap in the design rather than in the tuning, and no threshold closes it, because there is nothing to count: one request, one path, one resource, and a response size that is unremarkable in absolute terms while being everything you have.
Detecting that needs a different shape of signal altogether. Something like a single request carrying a high $top against a directory collection, judged on response size relative to what that identity normally pulls. Which is a harder query than anything here, and it needs a baseline you will not have on your first day.
So verify in your own workspace, and now you know roughly how long you may be waiting before you can.
The part worth carrying away is what this implies about everyone else. If that is what it took me, actively trying, consider the tenants where nobody ever checked. They have a Graph activity detection sitting in their SIEM, a green tick beside it, and no way of telling whether there is anything behind it.
Beyond volume, the behavioural indicators worth alerting on are user agents that no first-party Microsoft component uses, breadth across distinct object types in a short window, sustained paging, and 4xx surges that indicate permission probing rather than legitimate use. That last one is underrated: an attacker mapping what a compromised identity can reach generates a burst of 403s that looks nothing like normal application traffic, and it happens before the successful reads do.
On user agents specifically, Elastic Security Labs published detection research on directory enumeration that is worth reading, with the caveat that it targets the legacy Azure AD Graph activity logs rather than the Microsoft Graph table used above. The tooling families it names, AzureHound, BloodHound and AADInternals, and the generic HTTP client agents like aiohttp, curl and Go-http-client, transfer across as a starting signal list. The logic is sound: no first-party Microsoft component identifies itself that way, while default offensive tooling does. It is a low-effort, high-signal filter, and equally it is one line of configuration for an attacker to defeat, so treat it as a tripwire rather than a control.
What this adds up to
Nothing here is exotic. It is a device compliance policy, a consent policy, a guest role setting, some attribute hygiene and a Log Analytics table. The reason directories keep getting exported wholesale is not that the controls are hard. It is that read-only quietly got filed as low-risk, and nobody went back to check.
If you do two things from this post, make them the device compliance policy and the consent policy, in that order, and understand that you need both because they defend different routes to the same outcome. If you do three, add the Graph activity logs, then spend a day proving they are delivering. The first two narrow the aperture. The third tells you nothing at all until you have sent known traffic through it and counted that traffic back out of the table, and the assumption that it works because the tick is green is the failure this post spent longest on.
One thing I would genuinely like back. If you run the single-request check against your own tenant, tell me whether /users honoured $top=999 or capped you at a hundred. One measurement per tenant is how anybody establishes whether the single-request export is the ordinary case, or whether mine was the odd one.
Verify explicitly has been in the Zero Trust guidance from the beginning. Applying that to write operations and waving through reads is not a partial implementation. It is the wrong half.
Stay in the loop
New articles on M365 architecture, security baselines, and automation. No spam, just practical engineering content.