UAL – the forensic artefact most incident responders forget

Mapping out the log landscape is a crucial part of incident response. First responders typically collect endpoint logs, identity logs and application logs, and ship them to the analyst team who start the analysis. Those three are usually well covered. The one that often keeps getting left out is the Microsoft 365 Unified Audit Log (UAL).

That is a problem, because the UAL is where user activity converges. Exchange, SharePoint, OneDrive, Teams and Entra ID all feed into it, which makes it the one place you can follow a compromised account across workloads in a single timeline. After an AiTM phishing case it is often the only source that answers what the attacker read, downloaded and shared. Yes, Activity Explorer in Purview covers some of the same ground, but only 30 days back, and it is built for browsing rather than collection.

When the UAL does get collected, it is typically done with Search-UnifiedAuditLog. My experience is that the cmdlet requires some fingerspitzgefuhl. Defaults return a fraction of the data, paging is manual, results come back unsorted and with duplicates, and the record counts the service reports include those duplicates. None of that is undocumented, but every one of them is something you have to know in advance, and the output looks equally clean whether you got it right or not.

There is also the permissions issue. Exchange Online only loads the cmdlets your role entitles you to. Connect with an account that lacks audit permissions and the session reports itself as connected with a valid token, then tells you the command does not exist. A permissions problem arrives disguised as a missing module, and you go looking in the wrong place.

Purview Audit Search Graph API

The better option is the Microsoft Purview Audit Search Graph API. It does exactly what the Audit solution in Purview does, except you can call it. Purview has run audit searches asynchronously for years, submitting the query, processing it in the background and handing back the results when it completes. The API is that same mechanism, exposed properly. Same engine, same records, same output. That last part matters more than it sounds: anything a script pulls can be reproduced in the portal by anyone who wants to check your work.

I tested both against the same tenant, the same user and the same period. The Search-UnifiedAuditLog cmdlet returned 832 records. The API returned 832. Identical data, which is the whole point. This was never about one source seeing more than the other. It is about what you are left holding when the extraction finishes. The cmdlet leaves you an unsorted object collection in a PowerShell session that you clean up yourself. The API run took 98 seconds end to end, slower for a set that size. What comes back is raw records with auditData intact, which means you decide what to do with them. In my case a script wrote them to disk as JSONL, built a sorted timeline alongside it, recorded who ran the extraction, when, against which UTC window and with which parameters, and hashed the lot with SHA-256. That is the version you can hand to another analyst and verify a year from now.

For the technically inclined

The API lives at /security/auditLog/queries and requires AuditLogsQuery.Read.All, available as both delegated and application permission, with granular per-service variants if you want to scope it down. You create a query, poll until the status moves from notStarted through running to succeeded, then page through /records until there are no more results. You get records rather than a generated export file, with auditData intact rather than flattened into a portal CSV, which leaves the output format up to you. Because it is Graph rather than Exchange Online PowerShell, app-only authentication with a certificate is available, which is what makes unattended collection realistic.

Three things to know before you build on it:

  1. Microsoft moved the API from v1.0 back to beta in April 2025 to address issues, and it is still there. The v1.0 endpoint currently returns a resource not found error. Use beta, and record the version in your case file.
  2. ExchangeOnlineManagement and Microsoft.Graph.Authentication load conflicting versions of the MSAL assembly and will not coexist in the same PowerShell process. Keep them in separate sessions.
  3. Watch your dates. Records are stored in UTC, but whatever you use to call the API decides how a date string gets interpreted before it goes out. I ran the same period through two tools and got windows two hours apart, because one read the date as UTC and the other as local time. Two extracts of what looks like the same period can cover different intervals, and nothing in the output tells you. Log the actual UTC boundaries you queried.

Where compliance comes in

The tooling only takes you as far as the data goes. Retention is 180 days on Purview Audit Standard and a year on Premium, rolling forward every day whether you are investigating or not. What gets recorded depends on which auditing is enabled and which SKU you hold, and whether anyone can extract it at two in the morning depends on role assignments made long before the incident. Those are compliance decisions, not incident response decisions, and they set the ceiling on what any investigation can establish afterwards.

Which is why this belongs on the map on day one. Not when the case is scoped, because by then the window has already moved. Preserve the UAL before anyone knows what they are looking for. It is the cheapest thing you will do that hour, and the one thing you cannot go back and redo.

..and here´s my script

A working script that does this over the API is on my GitHub: github.com/royallan/export_ual