Intro
Any time you agree to a way of reaching the mail system other than the standard one, you
end up with a governance problem. With POP3/IMAP/EWS (assuming you did not bother to set
Set-CASMailbox properly) it’s usually the nightmare of debugging text connection logs -
or, even worse, IIS W3C logs. With Graph API it is by design almost impossible: there is
no per-message protocol log you can just Get- from Exchange Online. There are some
reports, but - ironically - not in Exchange itself; they live on the admin.cloud.microsoft
site, and realistically an Exchange admin only sees them with the Exchange Administrator
(or Report Reader) role. And even that is not enough.
For Graph API most members holding the Security Reader role already have some access to
Microsoft Defender for Office 365 and to advanced hunting - a local, security-focused
buffer of events at https://security.microsoft.com/v2/advanced-hunting - and the ability
to run KQL queries against it. The table that finally fills the gap for Graph read
operations is GraphAPIAuditEvents.
Some queries examples
The first approach (and I recommend running it first for a single day, not a whole month) is to list every application ID that used Graph API in an Exchange context. Something like this:
GraphAPIAuditEvents
| where TargetWorkload == "Microsoft.Exchange"
or RequestUri startswith "/v1.0/users"
or RequestUri has "mail"
or RequestUri has "messages"
or RequestUri has "mailFolders"
| summarize Count = count() by ApplicationId
| sort by Count desc
Sometimes it’s necessary to verify permissions instead:
let exchangePerms = dynamic([
"Mail.Read",
"Mail.ReadBasic",
"Mail.ReadWrite",
"Mail.Send",
"MailboxSettings.Read",
"MailboxSettings.ReadWrite",
"MailFolders.Read",
"MailFolders.ReadWrite"
]);
GraphAPIAuditEvents
| where Scopes has_any (exchangePerms)
| summarize Count = count() by ApplicationId
| sort by Count desc
Note: double-check the scope names against the app’s actual API permissions.
MailFolders.Read/MailFolders.ReadWriteare not standard Graph scopes (mail folders are reached throughMail.Read*), so keep the list honest for your tenant.
And the typical problem - the list above is usually way too huge to analyze - means you will need to limit it to a few already known applications:
let appIds = dynamic([
"12345678-285b-4a8c-89dc-987654321012",
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"11111111-2222-3333-4444-555555555555"
]);
GraphAPIAuditEvents
| where ApplicationId in (appIds)
| project Timestamp, AccountObjectId, ServicePrincipalId, RequestMethod, RequestUri, ResponseStatusCode, Scopes
| sort by Timestamp desc
Obstacles
There are a few obstacles in this method of investigation - the ones typical for real-life KQL sessions:
- If you are too aggressive you can burn the whole short-term tenant query quota in one session.
- Mind the character case in table and column names - KQL is case-sensitive. I lost a few
hours because Microsoft documentation and some blog wrote the table as
GraphApiAuditEvents(with a lowercase “pi” in API), while in another place it was spelledGraphAPIAuditEvents. Use the casing that your Advanced Hunting actually resolves and stick to it. - The good news:
GraphAPIAuditEventsis part of the Defender XDR / advanced hunting tables, so there is no extra ingestion or storage cost - you only need the relevant portal access to query it.