How to Audit Historical SharePoint Permissions
SharePoint keeps no permission history. This guide shows what the audit log can and cannot reconstruct, how to build a baseline plus event approach with PowerShell, and how to state confidence honestly.
An auditor asks: "Who could read the payroll folder on 14 February?" SharePoint can tell you who can read it now. It has no table of past role assignments, no membership history, and no way to render the permissions page as it looked on a given date. Answering the question means reconstructing the state from whatever evidence you have, and being explicit about how good that evidence is.
This guide covers what is possible with Microsoft's own tooling, how to structure the reconstruction, and where it runs out.
What SharePoint does and does not keep
Kept: the current role assignments on every securable object, current SharePoint group membership, current sharing links, and current Entra ID group membership (in Entra ID).
Not kept: any previous value of the above. When a role assignment is removed, it is gone. When inheritance is reset, the unique assignments are discarded. When a user leaves a group, there is no "was a member until" field.
Kept for a while: audit events. The Unified Audit Log records SharePoint permission operations and Entra ID group membership operations, for 180 days on Audit (Standard) and one year on Audit (Premium), longer with the 10-year retention add-on. See How Long Does Microsoft 365 Keep SharePoint Audit History? for the details.
So a historical audit has two ingredients: a known state at some point (a baseline) and an ordered list of changes (events) between that point and the date in question. Without a baseline you cannot start; without events you cannot move.
The reconstruction model
Think of it as replaying a ledger:
state(T) = baseline(T0) + events(T0 .. T)
Given a full export of permissions at T0 and every relevant event between T0 and T, you can compute the state at T. The confidence of the answer depends on three things:
- How complete the baseline was. Did it cover every unique-permission object, every SharePoint group's members, and every directory group's transitive members?
- How complete the events are. Were all event types captured, and was the export taken before retention expired?
- How far T is from the nearest baseline. Longer distances accumulate more risk of a missed or ambiguous event.
If you are asked about a date before your earliest baseline, the honest answer is that you do not know. Do not extrapolate backwards.
Step 1: Take baselines you can replay from
A baseline needs to capture, per site collection:
- Every securable object with unique permissions, with its role assignments (principal, role definitions).
- Every SharePoint group with its direct members.
- Every Entra ID security group and Microsoft 365 group referenced, with transitive membership at that instant.
- Every sharing link (the
SharingLinks.*groups) with its scope and members.
A minimal PnP PowerShell sweep for the unique-permission objects in a site:
Connect-PnPOnline -Url $SiteUrl -Interactive -ClientId $ClientId
$capturedAt = (Get-Date).ToUniversalTime().ToString("o")
$rows = foreach ($list in Get-PnPList -Includes HasUniqueRoleAssignments, RoleAssignments) {
if ($list.Hidden) { continue }
$items = Get-PnPListItem -List $list -PageSize 2000 -Fields "FileRef","HasUniqueRoleAssignments"
foreach ($item in $items | Where-Object { $_["HasUniqueRoleAssignments"] }) {
$ras = Get-PnPProperty -ClientObject $item -Property RoleAssignments
foreach ($ra in $ras) {
Get-PnPProperty -ClientObject $ra -Property Member, RoleDefinitionBindings | Out-Null
[pscustomobject]@{
CapturedAtUtc = $capturedAt
Site = $SiteUrl
Resource = $item["FileRef"]
Principal = $ra.Member.LoginName
PrincipalType = $ra.Member.PrincipalType
Roles = ($ra.RoleDefinitionBindings | ForEach-Object Name) -join ";"
}
}
}
}
$rows | Export-Csv "baseline-$($capturedAt -replace '[:.]','-').csv" -NoTypeInformation
Add the web-level and list-level role assignments, then the group expansions from the group comparison guide. Store the capture timestamp in UTC on every row; local time will betray you the first time a daylight-saving change falls inside your window.
Take baselines on a schedule. Weekly is a reasonable starting point; the shorter the gap, the fewer events you need to replay and the more resilient you are to a missed one.
Step 2: Export the events continuously
Retention is the enemy here. A retention window of 180 days means an event export you did not run in time is lost. Export on a schedule and keep the results.
Using the Exchange Online PowerShell module:
Connect-ExchangeOnline
$start = (Get-Date).AddDays(-1).ToUniversalTime()
$end = (Get-Date).ToUniversalTime()
$ops = @(
"SharingInheritanceBroken","SharingInheritanceReset",
"PermissionLevelAdded","PermissionLevelRemoved","PermissionLevelModified",
"AddedToGroup","RemovedFromGroup","GroupAdded","GroupRemoved",
"SharingSet","SharingRevoked","SharingLinkCreated","SharingLinkDisabled",
"SecureLinkCreated","AddedToSecureLink","RemovedFromSecureLink",
"AnonymousLinkCreated","AnonymousLinkRemoved",
"SiteCollectionAdminAdded","SiteCollectionAdminRemoved"
)
$sessionId = [guid]::NewGuid().ToString()
$all = do {
$batch = Search-UnifiedAuditLog -StartDate $start -EndDate $end -RecordType SharePoint `
-Operations $ops -SessionId $sessionId -SessionCommand ReturnLargeSet -ResultSize 5000
$batch
} while ($batch.Count -eq 5000)
$all | Select-Object CreationDate, Operations, UserIds, AuditData |
Export-Csv "spo-events-$($start.ToString('yyyyMMdd')).csv" -NoTypeInformation
Run a second query with -RecordType AzureActiveDirectory for Add member to group. and Remove member from group. so directory group changes are captured too. Microsoft also exposes the same data through the Microsoft Graph audit log query API (security/auditLog/queries) if you prefer Graph over Exchange Online PowerShell.
The AuditData JSON carries the details: ObjectId (the resource URL), TargetUserOrGroupName, EventData with the permission level, and for SharingInheritanceBroken the object that became unique. Parse it once and store it in columns.
Step 3: Replay to the requested instant
With a baseline at T0 and events in order, apply each event to the in-memory state:
| Event | Effect on state |
|---|---|
SharingInheritanceBroken | Object becomes a boundary; copy parent assignments to it |
SharingInheritanceReset | Object stops being a boundary; discard its assignments |
PermissionLevelAdded / Modified / Removed | Add, change or remove the role binding for the principal on the object |
AddedToGroup / RemovedFromGroup | Update the SharePoint group's direct membership |
Add member to group. / Remove member from group. (Entra ID) | Update the directory group's membership |
SharingLinkCreated / SecureLinkCreated / AddedToSecureLink | Add a link principal and its members on the item |
SharingRevoked / link removed events | Remove the link principal |
Stop when the next event is after T. The state you hold is your reconstructed answer. Then resolve the specific question, "could Alex read Payroll," by walking the chain exactly as in How to Find Out Why a User Has Access to a SharePoint Site, but against your reconstructed tables instead of live SharePoint.
Step 4: State the confidence
Attach a confidence label to every answer and mean it:
- Verified snapshot: T coincides with a baseline; the answer is directly observed.
- Reconstructed: T is between baselines and every event in the interval was captured and applied cleanly.
- Partial: part of the path depends on data with a known gap, for example a nested group whose membership was first captured after T, or an event type you were not exporting at the time.
- Unknown: T is before your earliest baseline.
When the next baseline disagrees with your replayed state, you have found a missed event or an ambiguity. Record the discrepancy as a correction rather than silently adjusting; the audit trail of your audit matters too.
Where the manual approach breaks
It works for one site and one question. It does not scale to a tenant with thousands of unique-permission objects, dozens of directory groups per site and a 180-day retention clock ticking on every event. The scripts above also run against a tenant that is throttling you, and a throttled export that silently returns fewer rows is worse than no export at all: check for 429 responses and retry with the Retry-After value rather than looping.
FAQ
- Can I use SharePoint versioning to see old permissions?
- No. Version history captures document content and metadata, not role assignments. Permissions are not versioned anywhere in SharePoint Online.
- Does the Entra ID sign-in log tell me what a user accessed?
- Sign-in logs record authentication to applications, not authorization to specific documents. SharePoint file access events (FileAccessed and similar) in the Unified Audit Log show what was opened, which is evidence of use, not of entitlement.
- How far back can I answer if I start today?
- Your coverage starts at your first complete baseline. You can extend it slightly backwards by replaying audit events in reverse from that baseline, but only for the retention window still available, and with reduced confidence.
Related reading
Related guides
All guides- SharePoint Permissions11 min read, intermediate
How to Find Out Why a User Has Access to a SharePoint Site
Trace direct, inherited and nested group access in SharePoint Online, from the role assignment on the resource down to the identity, using the admin UI, REST and PowerShell.
- Compliance & Audit10 min read, intermediate
How to Investigate Who Changed SharePoint Permissions
A step-by-step method for attributing a SharePoint Online permission change to an actor and a timestamp, reading the audit record, and reconstructing the before and after state.
- Microsoft 365 Security9 min read, beginner
How Long Does Microsoft 365 Keep SharePoint Audit History?
Retention periods for the Unified Audit Log under Audit (Standard) and Audit (Premium), what the 10-year add-on covers, how audit delay works, and why none of it is a permission history.