Top 10 Microsoft Graph traps
Microsoft Graph looks uniform, yet permissions, query options and mailbox rules differ by endpoint and licence. These ten traps cost the most time when building on Graph, whether from a script, a service or SPFx.
- Tenant-wide enumeration in Graph is app-only — check Delegated vs Application before you buildTenant wide calls such as getAllSites reject delegated tokens; saves a redesign late in the project.getAllSites rejects delegated tokens; check the Permissions table first
- az CLI can't grant Sites.Selected — the scope is blocked by AADSTS65002The usual way to scope an app to a few sites cannot be granted with az CLI; the PowerShell SDK route that does work.sites/{id}/permissions needs Sites.FullControl.All; the CLI client can't request it (AADSTS65002), use the Graph PowerShell SDK
- A comma in a Graph permissions table can mean AND — searching Teams messages needs two scopesSome endpoints need two permissions listed with a comma; read the 403 text instead of trusting the table alone.Searching chatMessage needs Chat.Read and ChannelMessage.Read.All; the 403 spells it out, the table does not (+ webUrl vs webLink from the same endpoint)
- Graph /me/sendMail — From is always the signed-in userDelegated sendMail always sends as the signed-in user and cannot impersonate; set Reply-To for the address you meant.Delegated Mail.Send can't impersonate; configure Reply-To instead
- The Microsoft Graph JS client sends $filter values unencoded — & splits the query, # cuts it offThe JS client passes $filter values into the URL as is, so # and & break queries; escape and encode them yourself..filter(), .query() and .api('…?…') pass the value into the URL as-is (SDK 3.0.2, also under SPFx MSGraphClientV3): # cuts the query, & splits it and .api() even moves $top into the filter; double apostrophes, then…
- directoryObject collections reject $select of user fields — and $top is a separate, per-endpoint trapUser fields on members and transitiveMembers fail with 400; the OData cast that fixes $select and how to probe $top.User fields on /members//transitiveMembers = 400; OData cast cures $select everywhere, $top stays per-endpoint (probe both)
- $filter on /groups/{id}/members needs $count=true, not just ConsistencyLevelFiltering group members needs $count=true besides the header; without it the call fails every time, not just now and then.The header is only half the recipe; a permanently failing call hid behind a best-effort catch as a "temporary outage"
- Adding or removing a group member via Graph needs the object ID — a UPN returns 400 "Invalid object identifier"members/$ref takes only an object ID; a UPN from a people picker returns 400 until you resolve it first.POST /groups/{id}/members/$ref and DELETE /groups/{id}/members/{id}/$ref accept only the user's object ID (GUID); a UPN from a people picker returns 400 Invalid object identifier — resolve it with GET…
- A mail-permission probe that can't tell "no mailbox" from "no consent" lies to adminsA missing mailbox and missing consent both look like failure; separate verdicts stop you telling admins to grant what they have./me/messages 404 MailboxNotEnabled* on mailbox-less accounts ≠ missing consent; three verdicts, and never cache a negative probe result
- signInActivity without Entra ID P1 — the retry without it turns "not measured" into "0 inactive users"Without Entra ID P1 the signInActivity fallback makes inactive user reports show zero; record not measured instead.Selecting signInActivity on /users fails with 403 in tenants without Entra ID P1/P2; the common fallback (retry without the property) makes every user look "never signed in" or gets skipped, so inactive-user and…