Contents

Detect threats using GraphAPIAuditEvents - Part 3

For a long time now, defenders had the ability to monitor behavior of human- and workload identities in Entra tenants not only through AuditLogs but with high level of insight with the MicrosoftGraphActivityLogs logs. The last two articles of this series gave you detection ideas and hunting queries for this logs source and were meant as a kick starter for detection engineers. But in the end the high cost of this log prevented many companies from putting it into operation. This is about to change with the release of GraphAPIAuditEvents logs in the XDR portal.

GraphAPIAuditEvents

In July 2025 this new table was releasedin public preview: GraphAPIAuditEvents.

And right from the start a difference between the Learn article and the actual schema in the tenant caught my eye.

The following columns were documented but not yet in the actual data.

  • IPAddress
  • UniqueTokenIdentifier

These columns are, in my opinion, very important for detection engineers. Luckily Microsoft updated the data in the meantime and these two columns are now available which opens up the great potential of is this dataset.

There are still differences between the paid Sentinel table and the XDR table and my friend Thomas Naunheim did a great job and published a comparison table. With his blessing I can can use it here to show you the differences.

/en/detect-threats-graphapiauditevents-part-3/images/table-comparison.png
Comparison table by Thomas Naunheim

But enough about schemas, differences and timelines, let’s get into the actual data.

AzureHound detection

One of the core detection ideas of the initial blog series was a behavior based detection method that relies on normalized data from the RequestUri column and the knowledge which endpoints the tool is requesting.

For AzureHound we can test this, as the binary is available and super easy to use. Let’s borrow a access token for Microsoft Graph from a unsuspected test account and run AzureHound in the default configuration.

/en/detect-threats-graphapiauditevents-part-3/images/azurehound.png
AzureHound

After some time the tenant information is fully dumped. So we head over to the Defender portal and have a look at the endpoints the user has queried. If we want to use the IdentityInfo table to resolve the object ID of the user, we need to have a longer lookback time as the actual time period we want to query. This is the reason I used Timestamp twice in this query.

IdentityInfo
| where Timestamp > ago(14d)
| where AccountUpn == "quellcrist.falconer@c4a8korriban.com"
| summarize by AccountObjectId
| join ( GraphAPIAuditEvents | where Timestamp > ago(1h) ) on AccountObjectId
| project-reorder Timestamp, AccountObjectId, RequestUri, Location, Scopes, UniqueTokenIdentifier

/en/detect-threats-graphapiauditevents-part-3/images/identityinfojoined.png
Join the tables for more fun ;)

1605 requests by a single user in such a short period of time is already not normal in my environment.

/en/detect-threats-graphapiauditevents-part-3/images/timechart.png
One of those users had different goals

GraphAPIAuditEvents
| summarize count() by bin(Timestamp, 1h), AccountObjectId
| where count_ > 100
| render timechart 

But when we look at the endpoints this unique user queried in this time period it get’s even more interesting. At first it looks like another 1595 unique requests made.

GraphAPIAuditEvents
| where AccountObjectId == "db5ccf9c-9965-4659-bd76-97102d03b3f3"
| summarize count() by RequestUri

But when I only extract the actual endpoint by normalizing the RequestUri only 39 unique endpoints are actually requested.

GraphAPIAuditEvents
| where AccountObjectId == "db5ccf9c-9965-4659-bd76-97102d03b3f3"
| extend RequestUri = replace_regex(RequestUri, @'[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}', @'<UUID>')
| extend RequestUri = replace_regex(RequestUri, @'\?.*$', @'')
| summarize count() by RequestUri

/en/detect-threats-graphapiauditevents-part-3/images/normalizeduri.png
The Uri, you must normalize. Hmm, yes.

Since I already had the query made for the MicrosoftGraphActivityLogs table I was able to quickly adapt it to the new GraphAPIAuditEvents. With this I now had a working detection for AzureHound. I also include one addition to the original query, you can now filter out activity based on the total requests made in the time period. This can be helpful to tune the query to your environment.

let AzureHoundGraphQueries = dynamic([
    "https://graph.microsoft.com/beta/servicePrincipals/<UUID>/owners",
    "https://graph.microsoft.com/beta/groups/<UUID>/owners",
    "https://graph.microsoft.com/beta/groups/<UUID>/members",
    "https://graph.microsoft.com/v1.0/servicePrincipals/<UUID>/appRoleAssignedTo",
    "https://graph.microsoft.com/beta/applications/<UUID>/owners",
    "https://graph.microsoft.com/beta/devices/<UUID>/registeredOwners",
    "https://graph.microsoft.com/v1.0/users",
    "https://graph.microsoft.com/v1.0/applications",
    "https://graph.microsoft.com/v1.0/groups",
    "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments",
    "https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions",
    "https://graph.microsoft.com/v1.0/devices",
    "https://graph.microsoft.com/v1.0/organization",
    "https://graph.microsoft.com/v1.0/servicePrincipals"
    ]);
GraphAPIAuditEvents
| where ingestion_time() > ago(1h)
| extend ObjectId = coalesce(AccountObjectId, ApplicationId)
| where RequestUri !has "microsoft.graph.delta"
| extend NormalizedRequestUri = replace_regex(RequestUri, @'[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}', @'<UUID>')
| extend NormalizedRequestUri = replace_regex(NormalizedRequestUri, @'\?.*$', @'')
| summarize
    TotalCount = count(),
    GraphEndpointsCalled = make_set(NormalizedRequestUri, 1000),
    arg_min(Timestamp, *)
    by ObjectId, EntityType
| extend MatchingQueries=set_intersect(AzureHoundGraphQueries, GraphEndpointsCalled)
| extend ConfidenceScore = round(todouble(array_length(MatchingQueries)) / todouble(array_length(AzureHoundGraphQueries)), 1)
| where ConfidenceScore > 0.7
| where TotalCount > 1000
| project-away
    NormalizedRequestUri,
    RequestUri,
    ResponseStatusCode,
    RequestMethod,
    RequestDuration,
    ApiVersion

Sadly my second detection for AzureHound which worked as a near real-time detection is no longer possible. But since this only caught the laziest adversary, as it relied on the UserAgent to trigger, it’s not that big of deal that this field is not available.

Correlation with Sign-in logs

I mentioned that IP address and UniqueTokenIdentifier (UTI) are crucial fields and especially for the latter one this is true for hunting.

The UTI allows you to not only correlate the actual action to a specific sign in event, but because you will retrieve the session ID from those logs you can build a full impact analysis for any compromised session.

Microsoft themselves recently released a blog post on the importance of those identifiers

Strengthen identity threat detection and response with linkable token identifiers

UTI is available in the regular SigninLogs and therefor can be used to map GraphAPIAuditEvents to the original sign-in event. Sadly the same it not true for the AADSignInEventsBeta, as it only contains the SessionId. Hopefully if this table leaves the beta state it will also contain UTI.

So for now you still need some log data in Sentinel, but when you have Unified Advanced Hunting enabled you can query both datasets from the Defender portal.

With this query you will get the sign-in events of the user and from there can use the SessionId to correlate other logs to identify what the attacker might have done in other parts of Microsoft 365.

GraphAPIAuditEvents
| where RequestUri !has "microsoft.graph.delta"
| extend NormalizedRequestUri = replace_regex(RequestUri, @'[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}', @'<UUID>')
| extend NormalizedRequestUri = replace_regex(NormalizedRequestUri, @'\?.*$', @'')
| summarize
    GraphEndpointsCalled = make_set(NormalizedRequestUri, 1000),
    IpAddresses = make_set(IpAddress)
    by UniqueTokenIdentifier
| join kind=inner (UnifiedSignInLogs
    | where ResultType == 0
    | project
        UserPrincipalName,
        AppDisplayName,
        ResourceDisplayName,
        IPAddress,
        UniqueTokenIdentifier,
        SessionId)
    on UniqueTokenIdentifier
| project-away UniqueTokenIdentifier1

/en/detect-threats-graphapiauditevents-part-3/images/uti.png
UniqueTokenIdentifier are a great way to track unique sign-in events

Summary

Releasing this dataset to every XDR customer is a great move by Microsoft, as it will allow more companies to leverage the information.

I hope that my queries and the detection example helps you to get started to build and share your own detections.

You find all KQL detections for AzureHound, GraphRunner and Purple Knight in my GitHub repository.

Further reading and thanks

Bert-Jan translated his hunting queries to the new table, which you can find in his GitHub repository.

Thanks again to Thomas Naunheim for the great comparisons of the two tables.

GraphApiAuditEvents (Preview) on learn.microsoft.com