Every tenant keeps a flight recorder. Someone deletes a mailbox, resets an MFA method, shares a SharePoint site with the whole company, exfiltrates a folder from OneDrive—it all lands in one append-only log that nobody looks at until the day they have to. That log is the unified audit log, and the tool almost everyone reaches for to read it is a single PowerShell cmdlet: Search-UnifiedAuditLog.
Message Center post MC1505599 (surfaced by Office365ITpros on 19 October) says that cmdlet retires in October 2027. The replacement—the AuditLogQuery API in Microsoft Graph—is now generally available. So you have twelve months. Not to panic. To inventory, test, and cut over without discovering on the morning of a legal hold that your evidence-gathering script returns nothing.
Let me build this up from the mechanism, because the migration pain isn’t the syntax. It’s that the new thing works on a fundamentally different model, and if you port your scripts line-for-line you’ll get something that looks right and behaves wrong.
What the cmdlet actually did for you
Think of Search-UnifiedAuditLog as walking up to a records counter and asking a question out loud. “Show me every ExchangeAdmin operation from the last seven days.” The clerk goes away, comes back, and hands you a stack of records right there at the window. If the stack is big, you ask for the next batch with a session ID. Synchronous. Conversational. You stand there until you have what you came for.
A typical pull looked like this:
This pattern is load-bearing across the whole discipline. Compliance evidence pulls. Insider-threat timelines. “Who deleted the finance channel” incident response. Scheduled runbooks that email a weekly admin-activity summary. Conditional Access change tracking. If you’ve been an M365 admin for more than a year, you have scripts that call this, and you probably forgot half of them existed.
That’s the real exposure. Not the one script you maintain. The fifteen you inherited.
What “retired” means—and what nobody has told us
MC1505599 gives the date: October 2027. It does not spell out the exact failure mode on that day—whether the cmdlet throws, returns empty, or gets pulled from the module outright. So don’t plan around a guess. Plan around the cmdlet being gone, because every other interpretation is wishful. If your audit pipeline tolerates a cmdlet that silently returns zero rows, you have a bigger problem than this migration.
The new model: submit a ticket, come back later
Here’s the shift that trips everyone. AuditLogQuery is not a counter you talk to. It’s a ticketing system.
You submit a job describing what you want. Graph hands you back a job ID and starts working in the background. You poll until the job’s status flips to succeeded. Then you collect your records, paging through them with @odata.nextLink. Three distinct phases—create, wait, retrieve—where the cmdlet had one.
That asynchronicity is the single biggest behavioural change, and it’s why a naive port fails. Your old script assumed results existed the instant the call returned. The new one has to wait for a job that might take seconds or minutes depending on range and volume.
The API lives at /security/auditLog/queries. Creating a query is a POST:
Two things to notice immediately. The date format is strict ISO 8601 in UTC—yyyy-MM-ddTHH:mm:ssZ. The cmdlet was forgiving about DateTime objects and local time; Graph is not. Send it a local-time string and your window silently shifts by your UTC offset. And the record type is now a camel-case string literal (exchangeAdmin), not the PascalCase enum value you typed before. Small, but it’ll cost you an afternoon.
Polling and pagination, done properly
Now you wait for the job, then drain the records. This is where error handling earns its keep—network blips, throttling (HTTP 429), and the occasional failed status all need to be handled, not assumed away.
Notice there’s no -WhatIf here—this is a read, and reads don’t need a safety. But the moment you pipe these results into something that acts—disabling accounts flagged in the log, say—put the guardrail back. Export first, eyeball the CSV, then feed a reviewed file into your remediation step. Never let a live audit query drive a bulk change in one breath.
The differences that will actually bite you
Authentication for unattended jobs. This is the one that breaks Azure Automation runbooks and scheduled tasks. Search-UnifiedAuditLog often ran on a stored credential or a service account signed into Exchange Online. AuditLogQuery wants Graph permissions. Interactive admins get by with delegated AuditLogsQuery.Read.All. Automation needs app-only auth: an app registration with the application permission granted and admin-consented, authenticating via certificate. If you have runbooks still leaning on a service account’s delegated session, that’s a rebuild, not a tweak—budget for it early.
Latency is now real. The job model means a script that used to return in two seconds might take a minute. Anything with a tight timeout—an Automation runbook, a Logic App, a Function—needs its timeout revisited and a sane polling interval with backoff.
Throttling behaves differently. You’re now a Graph citizen subject to Graph throttling. Honour the Retry-After header. The snippet above does; your first draft won’t, because the cmdlet never made you think about it.
Result ceilings and date windows. Confirm the per-query record ceiling and the maximum lookback against current Graph docs before you assume parity with the cmdlet’s ResultSize behaviour. If you were pulling very large sets with ReturnLargeSet, plan to slice big investigations into smaller date windows and stitch the results—more jobs, more polling, same data.
Your twelve-month plan
- Inventory first. Grep every script repo, runbook, scheduled task, and Function for
Search-UnifiedAuditLog. Don’t forget the ones buried in someone’sDocumentsfolder that email the SOC every Monday. - Classify by trigger. Interactive (an admin runs it) versus unattended (it runs itself). The unattended ones carry the auth rebuild and are your long pole.
- Stand up app-only auth now. Register the app, grant
AuditLogsQuery.Read.Allas an application permission, get admin consent, wire up certificate auth. Do this in a test tenant and prove it before you touch production. - Port one high-value script end to end. Validate that the new query returns the same records as the old cmdlet for an identical window. Reconcile the counts. This is where you’ll catch the date-format and record-type-casing gotchas on your terms, not during an incident.
- Rebuild the automation, add backoff. Polling loop,
Retry-Afterhandling, longer timeouts. - Run old and new in parallel. For a few weeks, let both produce output and diff them. Trust is earned by matching results, not by the calendar hitting a deadline.
- Decommission deliberately. Only retire the cmdlet-based version once the Graph version has matched it in production for a full reporting cycle.
Where does this live? The audit data itself is still the Audit solution in the Microsoft Purview portal; that UI isn’t going anywhere. The app registration and permission grant are in the Entra admin center under App registrations. What’s changing is purely how your code reaches the log.
I ported one weekly compliance runbook over a weekend as a dry run. The query logic took twenty minutes. The app-only auth, the polling loop, and reconciling why my record counts were off by a timezone took the rest of Saturday. Start with the script you’d least want to be rewriting at 2 a.m. during an investigation. That’s the one that tells you how long this really takes.
