Security

User.Read.All: the Silent Reconnaissance That Precedes the Real Attacks

Case study: preventing a compromised application from mapping your directory for targeted spear-phishing.

GraphWarden Team·April 29, 2026·8 min read

The scenario

A 2,000-employee company uses an external HR dashboard that consumes User.Read.All to produce live org charts. The application has been in place for four years, nobody questions it, it works.

An intern at the HR vendor accidentally pushes an API key into a public GitHub repo. An attacker scanning for leaks picks it up on Tuesday. For 72 hours, apparent silence: nobody on the client side notices anything unusual. The HR app has always made a lot of /users calls.

In reality, the attacker is downloading the full directory. Names, titles, managers, departments, phone numbers, office addresses, hire dates. Six weeks later, a perfectly calibrated email goes out from john.smith-ceo@company-lookalike.com to the financial controller of the UK subsidiary. Subject: an urgent wire request signed as her VP Finance, with the right hierarchy, the right tone, the right project context. £840,000 leave.

The post-incident investigation traces the origin three months later: the HR vendor's key. The Microsoft audit never raised a flag, and that was normal. No rule had been broken.

Why Microsoft doesn't block this scenario

User.Read.All is by design the permission that serves address books, org charts, HR tools, Power BI analytics, CRMs that enrich contacts. It is the most-granted permission in the Graph catalog after User.Read.

The problem is not the permission itself. It is the complete absence of granularity on its use:

  • No distinction between reading the 50 managers of a department and downloading all 2,000 users in the tenant.
  • No distinction between name and email address and title, manager, department, phone. All those attributes come out through the same /users call.
  • Graph audit records "the app read /users," not "the app enumerated 2,000 users with a full projection."
  • Defender for Cloud Apps can flag mass scraping patterns, but with delay, and on an active tenant a normal HR signature is hard to distinguish from enumeration.

This is the most dangerous permission because malicious use is indistinguishable from legitimate use under native instrumentation. Same logic as the other high-privilege Graph permissions: a Microsoft-side switch, no application firewall. See also the Graph API permissions blind spot.

What the incident looks like with a perimeter in place

With /users calls routed through a governance proxy. Baseline: default-deny.

Rule 1 — Allowed attributes (controlled projection)

- name: "HR dashboard — field projection"
  endpoint_pattern: "/users"
  http_methods: [GET]
  conditions:
    all_of:
      - caller_identity:
          application_id: "app-hr-dashboard"
      - query_select:
          allowed: ["displayName", "mail", "jobTitle", "department", "manager"]
          deny_all_others: true
  action: allow

The HR app needs five attributes to build its org chart. The proxy enforces $select on the outbound request and rejects requests that ask for mobilePhone, officeLocation, employeeHireDate, or the wildcard. The attacker loses the context fields that make spear-phishing convincing.

Rule 2 — Bounded pagination

- name: "HR dashboard — pagination cap"
  endpoint_pattern: "/users"
  conditions:
    all_of:
      - pagination_cap:
          max_pages: 5
          page_size_max: 100
          scope: "caller_identity"
          window_seconds: 3600
  action: allow

500 users per hour is plenty for a normal HR refresh. Beyond that, it is enumeration. The preventive cap cuts off a full directory download before it can happen.

Rule 3 — Directory scope

- name: "HR dashboard — scope to business units"
  endpoint_pattern: "/users"
  conditions:
    all_of:
      - query_filter_required:
          path: "companyName"
          in: ["Acme Canada", "Acme France"]
  action: allow

The HR application is contracted for two subsidiaries only. The rule forces a $filter on companyName in every request. UK subsidiary users stay structurally invisible to the application, and therefore to the attacker.

Rule 4 — Enumeration pattern detection

- name: "HR dashboard — enumeration pattern"
  endpoint_pattern: "/users"
  conditions:
    any_of:
      - pattern_detect:
          signal: "sequential_skiptoken"
          threshold: 10
      - pattern_detect:
          signal: "wildcard_filter"
  action: block

Ten consecutive $skiptoken requests, or a startswith(displayName, '') filter that sweeps everything, are two classic reconnaissance signatures. Preventive blocking cuts scraping by the fifth page.

Rule 5 — Structural protection of executives

- name: "HR dashboard — executive shield"
  endpoint_pattern: "/users/*"
  http_methods: [GET]
  conditions:
    any_of:
      - object_attribute:
          path: "memberOf.displayName"
          contains: "Executive-Shield"
  action: block

The Executive-Shield group holds high-target users (C-suite, finance, HR leadership). Standard HR applications do not read these profiles. Tools that need them are explicitly tagged, others are blocked by identity. The financial controller's profile becomes unreachable to the compromised HR app, even for reads.

Conditions query_select, pagination_cap, and pattern_detect cover the reconnaissance surface. See the documentation for the full rule grammar.

See the rule documentation →

What changes for the decision-maker

Reconnaissance precedes targeted attacks by weeks or months. Classic SIEMs do not detect it because it happens through a legitimate application with a legitimate permission. The £840,000 incident from the scenario is attributable to the read that occurred months earlier. Nobody links the two in real time.

With a governance layer, three shifts.

  1. Attribute projection becomes explicit. Every application sees what it needs, not what Graph exposes by default.
  2. Mass reconnaissance becomes detectable and blockable. The cost of a key leak is visible at the proxy, not six months later in a forensic investigation.
  3. High-value targets are protected by group, not by obscurity. The executive shield is a rule, not a convention.

The limits worth naming

The projection rule (query_select) requires a precise inventory of what each application actually needs. Many HR deployments never document that list. Setting up a perimeter forces that work, and it is in practice one of the operational values of the project.

The Executive-Shield group requires maintenance. When someone is promoted or leaves, the group has to be kept current. It is a living control, not a set and forget rule.

Last limit: BEC does not always come from the Graph directory. LinkedIn, email signatures, public annual reports are other sources. The rule only covers one vector. It covers it well.

The takeaway

User.Read.All is the stealthiest permission in the high-risk family. It triggers no alert, no DLP, no investigation. It prepares the attacks that do cost real money.

For an organization that cares about its finance, executive, or legal teams, bounding User.Read.All with a declarative governance layer is cheaper than explaining, after the fact, how £840,000 left for an account nobody recognizes.

That is exactly why GraphWarden exists.

GraphWarden is built in Quebec, hosted in Canadian regions, and designed for environments where privacy law compliance and Microsoft Graph application control are non-negotiable requirements.