Short answer: run .\Get-EXOMailboxPermissionReport.ps1 -CsvPath .\mailbox-permissions.csv. It connects to Exchange Online and, for every user, shared, room and equipment mailbox, lists who has FullAccess (Get-EXOMailboxPermission), SendAs (Get-EXORecipientPermission) and SendOnBehalf (the GrantSendOnBehalfTo property), skipping NT AUTHORITY\SELF. Use -Trustee bob@contoso.com to see everything one person can open or send as.
Commands checked against the official documentation (linked below) on 6 October 2026; not yet run on our lab servers. The script was syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25 (no errors or warnings) but has not yet been run against a live Microsoft 365 tenant. Every cmdlet, endpoint, property and permission it uses was checked against Microsoft Learn. If something behaves differently for you, tell us and we will fix the script.
Table of Contents
What it does
Mailbox delegations pile up: a manager gets FullAccess to an assistant’s mailbox, a team gets SendAs on a shared mailbox, and nobody removes them when people change roles. Exchange Online keeps the three kinds of delegation in three different places, which is why a single cmdlet never gives you the whole picture:
| Permission | Where it lives | How the script reads it |
|---|---|---|
| FullAccess (open the mailbox) | Mailbox permission entries | Get-EXOMailboxPermission, non-inherited entries only |
| SendAs (send as the mailbox address) | Recipient permission entries | Get-EXORecipientPermission -AccessRights SendAs |
| SendOnBehalf (“Bob on behalf of Sales”) | Mailbox property | Get-EXOMailbox -Properties GrantSendOnBehalfTo |
- Uses the REST-based V3 cmdlets (
Get-EXO*) of the ExchangeOnlineManagement module, which Microsoft recommends for bulk reads. - Skips the
NT AUTHORITY\SELFentry every mailbox has, and inherited FullAccess entries (system accounts) unless you add-IncludeInherited. - Resolves trustee names to a primary SMTP address with
Get-EXORecipient(cached, so each trustee is looked up once). - Flags bare SIDs (
S-1-5-...) as orphaned: these usually belong to deleted users or groups and can be removed. - Reports Deny entries in a separate column instead of mixing them with grants.
- Read-only: no permission is added or removed.
Requirements
- Windows PowerShell 5.1 or PowerShell 7 with the
ExchangeOnlineManagementmodule, version 3 or later (Get-ConnectionInformation, used to reuse an open session, arrived in 3.0.0). - A role that can read mailboxes and their permissions. Read-only options are the Global Reader Entra role or an Exchange role group with view-only recipient rights; Exchange Recipient Administrator also works. To see which roles contain a cmdlet in your tenant, run
Get-ManagementRole -Cmdlet Get-MailboxPermission. - For scheduled runs: an app registration with
Exchange.ManageAsAppand a role on its service principal (see Schedule it).
Download and first run
- Copy the script from the box on this page (or use the download button) and save it as
C:\Scripts\Get-EXOMailboxPermissionReport.ps1. - If you downloaded the file, clear the “downloaded from the internet” mark so the execution policy lets it run:
Unblock-File C:\Scripts\Get-EXOMailboxPermissionReport.ps1. - Install the module once, for your user:
Install-Module ExchangeOnlineManagement -Scope CurrentUser. - Read the built-in help, then run it once interactively and look at the result on screen before you export or schedule anything.
cd C:\Scripts
Get-Help .\Get-EXOMailboxPermissionReport.ps1 -Full
.\Get-EXOMailboxPermissionReport.ps1 -Identity info@contoso.com
.\Get-EXOMailboxPermissionReport.ps1 -CsvPath C:\Reports\mailbox-permissions.csv
Start with one mailbox (-Identity) so you can compare the result with what the Exchange admin center shows, then run it for everything. If you are already connected with Connect-ExchangeOnline, the script reuses that session.
Options
| Parameter | What it does | Default |
|---|---|---|
-Identity | Only these mailboxes (UPN, SMTP address or alias) | All mailboxes |
-RecipientTypeDetails | Mailbox types: UserMailbox, SharedMailbox, RoomMailbox, EquipmentMailbox | All four |
-PermissionType | FullAccess, SendAs, SendOnBehalf (one or more) | All three |
-Trustee | Only rows where the trustee name or address matches (wildcards allowed; plain text is matched as *text*) | Everyone |
-IncludeInherited | Include inherited FullAccess entries | Off |
-CsvPath | Write a CSV file | Screen only |
-PassThru | Send the rows down the pipeline | Off |
-UserPrincipalName | Pre-fill the interactive sign-in | None |
-AppId, -Organization, -CertificateThumbprint | App-only sign-in for scheduled runs | Interactive |
-Disconnect | Close the Exchange Online session at the end | Off |
Usage examples
# Every delegation in the tenant
.\Get-EXOMailboxPermissionReport.ps1 -CsvPath C:\Reports\mailbox-permissions.csv
# Who can open or send as the shared mailboxes
.\Get-EXOMailboxPermissionReport.ps1 -RecipientTypeDetails SharedMailbox -PermissionType FullAccess,SendAs -CsvPath .\shared.csv
# Everything Bob has access to (run this when Bob leaves)
.\Get-EXOMailboxPermissionReport.ps1 -Trustee bob@contoso.com
# Orphaned entries left by deleted accounts
.\Get-EXOMailboxPermissionReport.ps1 -PassThru | Where-Object TrusteeAddress -like 'Orphaned*'
Large tenants: the script makes two calls per mailbox (FullAccess and SendAs). For thousands of mailboxes, run it per type (-RecipientTypeDetails SharedMailbox, then UserMailbox) or out of hours, and expect Exchange Online throttling to slow it down.
CSV columns
No sample output is shown because the script has not yet run against a live tenant. One row per delegation:
| Column | Meaning |
|---|---|
| Mailbox, MailboxAddress, MailboxType | The mailbox: display name, primary SMTP address and RecipientTypeDetails |
| Permission | FullAccess, SendAs or SendOnBehalf |
| Trustee | The user or group as Exchange returns it |
| TrusteeAddress | Primary SMTP address of the trustee, or “Orphaned SID (deleted user or group)” |
| AccessRights | Raw access rights (for example FullAccess or SendAs) |
| IsInherited | True for inherited entries (only with -IncludeInherited) |
| Deny | True when the entry denies the right instead of granting it |
Schedule it
For unattended runs use Exchange Online app-only (certificate) authentication, documented in App-only authentication in Exchange Online PowerShell. In short:
- Create an app registration in Microsoft Entra ID and upload the public key of a certificate whose private key is installed in the user certificate store of the account that will run the task.
- Add the Exchange.ManageAsApp application permission from Office 365 Exchange Online and grant admin consent.
- Assign a Microsoft Entra role to the app’s service principal (Microsoft lists which roles are supported). Global Reader is on that list and is read-only, so try it first; use Exchange Recipient Administrator only if a cmdlet is refused.
- Pass
-AppId,-Organization(your.onmicrosoft.comdomain, as Microsoft requires) and-CertificateThumbprint.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Get-EXOMailboxPermissionReport.ps1 -CsvPath C:\Reports\mailbox-permissions.csv -AppId <app-id> -Organization contoso.onmicrosoft.com -CertificateThumbprint <thumbprint> -Disconnect'
$trigger = New-ScheduledTaskTrigger -Daily -At 6am
Register-ScheduledTask -TaskName 'EXO mailbox permissions' -Action $action -Trigger $trigger -User 'CONTOSO\svc-reports' -Password '<password>'
The CSV is overwritten on every run. Keep the certificate’s private key on the reporting server only.
How it works
- Connects with
Connect-ExchangeOnline(interactive, or-AppId/-Organization/-CertificateThumbprint), unlessGet-ConnectionInformationshows an open ExchangeOnline session. Get-EXOMailbox -RecipientTypeDetails ... -Properties GrantSendOnBehalfTo -ResultSize Unlimitedlists the mailboxes. GrantSendOnBehalfTo is not in the default (Minimum) property set, so it is requested explicitly.- For each mailbox,
Get-EXOMailboxPermission -PrimarySmtpAddress ... -ResultSize Unlimitedreturns the FullAccess entries andGet-EXORecipientPermission -PrimarySmtpAddress ... -AccessRights SendAs -ResultSize Unlimitedthe SendAs entries. Both default to 1,000 results without-ResultSize. - GrantSendOnBehalfTo values are turned into SendOnBehalf rows.
- Trustees are resolved once each with
Get-EXORecipient; failures for one mailbox are reported as warnings and the run continues.
Limitations
- Folder permissions (for example Reviewer on a calendar) are not included; they are a different set of permissions (
Get-EXOMailboxFolderPermission). - Group trustees are shown as the group, not expanded to members.
- Microsoft 365 group mailboxes are not covered: their access comes from group membership.
- Speed: two calls per mailbox. A 5,000-mailbox tenant takes a while; that is normal.
- Not yet run against a live tenant (see the note at the top).
Official documentation: Get-EXOMailboxPermission · Get-EXORecipientPermission · Property sets in Exchange Online PowerShell cmdlets · App-only authentication in Exchange Online PowerShell
Related: Microsoft 365 shared mailbox vs distribution group vs Microsoft 365 Group · Employee Offboarding Checklist: Secure AD, M365 and Workspace Steps · Microsoft 365 SPF DKIM DMARC: Secure Exchange Online Setup · Email Header Analyzer
See also: Exchange Online Message Trace PowerShell: Get-MessageTraceV2 · Exchange Online Message Trace to CSV: Get-MessageTraceV2 Script · Exchange Server SE Upgrade from 2019 CU15: In-Place Steps
The script
# Exchange Online Mailbox Permissions Report: FullAccess, SendAs (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/exo-mailbox-permissions-report/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
<#
.SYNOPSIS
Exchange Online mailbox permissions report: FullAccess, SendAs and SendOnBehalf for every mailbox
(or selected mailboxes), filterable by trustee, exported to CSV.
.DESCRIPTION
Read-only. Uses the ExchangeOnlineManagement module (REST-based V3 cmdlets):
FullAccess Get-EXOMailboxPermission (non-inherited entries; NT AUTHORITY\SELF is skipped)
SendAs Get-EXORecipientPermission -AccessRights SendAs
SendOnBehalf GrantSendOnBehalfTo property from Get-EXOMailbox -Properties GrantSendOnBehalfTo
Trustees are resolved to a primary SMTP address with Get-EXORecipient where possible. Entries that are
bare SIDs (S-1-5-...) usually belong to deleted users or groups and are flagged as orphaned.
Permissions needed (read-only is enough):
Delegated sign-in: Microsoft Entra role Global Reader or Exchange Recipient Administrator, or an
Exchange Online role group that includes the recipient read roles (for example View-Only Organization
Management or Recipient Management). To check which roles contain a cmdlet in your tenant:
Get-ManagementRole -Cmdlet Get-MailboxPermission
App-only (certificate): app registration with the Office 365 Exchange Online Exchange.ManageAsApp
application permission (admin consent) and a supported Entra role assigned to the app's service
principal, for example Global Reader.
.PARAMETER Identity Only these mailboxes (UPN, SMTP address or alias). Default: all mailboxes.
.PARAMETER RecipientTypeDetails Mailbox types to include (default UserMailbox, SharedMailbox, RoomMailbox, EquipmentMailbox).
.PARAMETER PermissionType FullAccess, SendAs, SendOnBehalf (default: all three).
.PARAMETER Trustee Only show permissions held by this user or group (wildcards allowed, e.g. "bob*").
.PARAMETER IncludeInherited Include inherited FullAccess entries (normally system accounts only).
.PARAMETER CsvPath Write the result to this CSV file.
.PARAMETER PassThru Output objects to the pipeline.
.PARAMETER UserPrincipalName Admin account for interactive sign-in (optional; pre-fills the sign-in prompt).
.PARAMETER AppId App ID for certificate (app-only) sign-in.
.PARAMETER Organization Tenant's initial domain, e.g. contoso.onmicrosoft.com (app-only).
.PARAMETER CertificateThumbprint Certificate thumbprint (app-only; Windows certificate store).
.PARAMETER Disconnect Disconnect from Exchange Online when finished.
.EXAMPLE .\Get-EXOMailboxPermissionReport.ps1 -CsvPath .\mailbox-permissions.csv
.EXAMPLE .\Get-EXOMailboxPermissionReport.ps1 -RecipientTypeDetails SharedMailbox -PermissionType FullAccess,SendAs -CsvPath .\shared.csv
.EXAMPLE .\Get-EXOMailboxPermissionReport.ps1 -Trustee bob@contoso.com # everything Bob can open or send as
.EXAMPLE .\Get-EXOMailboxPermissionReport.ps1 -Identity info@contoso.com,sales@contoso.com
.EXAMPLE .\Get-EXOMailboxPermissionReport.ps1 -AppId <app-id> -Organization contoso.onmicrosoft.com -CertificateThumbprint <thumbprint> -CsvPath C:\Reports\perms.csv -Disconnect
.NOTES
Name: Get-EXOMailboxPermissionReport.ps1
Purpose: FullAccess / SendAs / SendOnBehalf audit for Exchange Online
Source: https://srvscripts.com/scripts/exo-mailbox-permissions-report/
License: MIT
Version: 1.0.0
Requires: Windows PowerShell 5.1 or PowerShell 7, module ExchangeOnlineManagement 3.x
(Install-Module ExchangeOnlineManagement -Scope CurrentUser).
#>
[CmdletBinding(DefaultParameterSetName = 'Interactive')]
param(
[string[]]$Identity,
[ValidateSet('UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox')]
[string[]]$RecipientTypeDetails = @('UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox'),
[ValidateSet('FullAccess', 'SendAs', 'SendOnBehalf')]
[string[]]$PermissionType = @('FullAccess', 'SendAs', 'SendOnBehalf'),
[string]$Trustee,
[switch]$IncludeInherited,
[string]$CsvPath,
[switch]$PassThru,
[Parameter(ParameterSetName = 'Interactive')]
[string]$UserPrincipalName,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F-]{36}$')]
[string]$AppId,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[string]$Organization,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F]{40}$')]
[string]$CertificateThumbprint,
[switch]$Disconnect
)
Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'
function Write-Status([string]$Message) { Write-Information $Message -InformationAction Continue }
# ---- Connect --------------------------------------------------------------------------------------------
if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) {
throw 'Module ExchangeOnlineManagement is not installed. Run: Install-Module ExchangeOnlineManagement -Scope CurrentUser'
}
Import-Module ExchangeOnlineManagement
$existing = @(Get-ConnectionInformation -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Connected' -and $_.Name -like 'ExchangeOnline_*' })
if (-not $existing) {
$c = @{ ShowBanner = $false }
if ($PSCmdlet.ParameterSetName -eq 'AppOnly') {
$c.AppId = $AppId; $c.Organization = $Organization; $c.CertificateThumbprint = $CertificateThumbprint
} elseif ($UserPrincipalName) {
$c.UserPrincipalName = $UserPrincipalName
}
Connect-ExchangeOnline @c
} else {
Write-Status ("Reusing Exchange Online connection ({0})." -f $existing[0].UserPrincipalName)
}
# ---- Mailboxes ----------------------------------------------------------------------------------------------
$props = @('GrantSendOnBehalfTo')
if ($Identity) {
$mailboxes = foreach ($id in $Identity) {
try { Get-EXOMailbox -Identity $id -Properties $props }
catch { Write-Warning "Mailbox not found or not readable: $id ($($_.Exception.Message))" }
}
$mailboxes = @($mailboxes | Where-Object { $_ })
} else {
Write-Status 'Reading mailboxes...'
$mailboxes = @(Get-EXOMailbox -RecipientTypeDetails $RecipientTypeDetails -Properties $props -ResultSize Unlimited)
}
Write-Status ("{0} mailbox(es) to check." -f $mailboxes.Count)
if (-not $mailboxes.Count) { return }
$cache = @{}
function Resolve-Trustee([string]$Name) {
if (-not $Name) { return '' }
if ($Name -match '^S-1-5-') { return 'Orphaned SID (deleted user or group)' }
if ($Name -match '^[^@\s]+@[^@\s]+$') { return $Name }
if ($cache.ContainsKey($Name)) { return $cache[$Name] }
$addr = ''
try {
$r = Get-EXORecipient -Identity $Name -ErrorAction Stop
if ($r) { $addr = [string]$r.PrimarySmtpAddress }
} catch { $addr = '' }
$cache[$Name] = $addr
return $addr
}
$rows = [System.Collections.Generic.List[object]]::new()
$n = 0
foreach ($m in $mailboxes) {
$n++
Write-Progress -Activity 'Reading mailbox permissions' -Status $m.PrimarySmtpAddress -PercentComplete ($n * 100 / $mailboxes.Count)
$base = @{ Mailbox = $m.DisplayName; MailboxAddress = [string]$m.PrimarySmtpAddress; MailboxType = [string]$m.RecipientTypeDetails }
if ($PermissionType -contains 'FullAccess') {
try {
foreach ($p in @(Get-EXOMailboxPermission -PrimarySmtpAddress $m.PrimarySmtpAddress -ResultSize Unlimited)) {
$user = [string]$p.User
if ($user -eq 'NT AUTHORITY\SELF') { continue }
if ($p.IsInherited -and -not $IncludeInherited) { continue }
if (($p.AccessRights -join ',') -notmatch 'FullAccess') { continue }
$rows.Add([pscustomobject]@{
Mailbox = $base.Mailbox; MailboxAddress = $base.MailboxAddress; MailboxType = $base.MailboxType
Permission = 'FullAccess'; Trustee = $user; TrusteeAddress = (Resolve-Trustee $user)
AccessRights = ($p.AccessRights -join ','); IsInherited = [bool]$p.IsInherited; Deny = [bool]$p.Deny
})
}
} catch { Write-Warning "FullAccess read failed for $($m.PrimarySmtpAddress): $($_.Exception.Message)" }
}
if ($PermissionType -contains 'SendAs') {
try {
foreach ($p in @(Get-EXORecipientPermission -PrimarySmtpAddress $m.PrimarySmtpAddress -AccessRights SendAs -ResultSize Unlimited)) {
$user = [string]$p.Trustee
if ($user -eq 'NT AUTHORITY\SELF') { continue }
$rows.Add([pscustomobject]@{
Mailbox = $base.Mailbox; MailboxAddress = $base.MailboxAddress; MailboxType = $base.MailboxType
Permission = 'SendAs'; Trustee = $user; TrusteeAddress = (Resolve-Trustee $user)
AccessRights = ($p.AccessRights -join ','); IsInherited = [bool]$p.IsInherited; Deny = ([string]$p.AccessControlType -eq 'Deny')
})
}
} catch { Write-Warning "SendAs read failed for $($m.PrimarySmtpAddress): $($_.Exception.Message)" }
}
if ($PermissionType -contains 'SendOnBehalf') {
foreach ($d in @($m.GrantSendOnBehalfTo | Where-Object { $_ })) {
$user = [string]$d
$rows.Add([pscustomobject]@{
Mailbox = $base.Mailbox; MailboxAddress = $base.MailboxAddress; MailboxType = $base.MailboxType
Permission = 'SendOnBehalf'; Trustee = $user; TrusteeAddress = (Resolve-Trustee $user)
AccessRights = 'SendOnBehalf'; IsInherited = $false; Deny = $false
})
}
}
}
Write-Progress -Activity 'Reading mailbox permissions' -Completed
$out = @($rows | Select-Object Mailbox, MailboxAddress, MailboxType, Permission, Trustee, TrusteeAddress, AccessRights, IsInherited, Deny)
if ($Trustee) {
$t = if ($Trustee -match '[*?]') { $Trustee } else { "*$Trustee*" }
$out = @($out | Where-Object { $_.Trustee -like $t -or $_.TrusteeAddress -like $t })
}
$out = @($out | Sort-Object MailboxAddress, Permission, Trustee)
if ($CsvPath) {
$out | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Status ("CSV written: {0} ({1} rows)" -f $CsvPath, $out.Count)
}
$summary = $out | Group-Object Permission | ForEach-Object { "{0} {1}" -f $_.Count, $_.Name }
Write-Status ("Delegations found: {0}" -f $(if ($summary) { $summary -join ', ' } else { 'none' }))
if ($Disconnect) { Disconnect-ExchangeOnline -Confirm:$false }
if ($PassThru) { return $out }
if (-not $CsvPath) { $out | Format-Table Mailbox, Permission, Trustee, TrusteeAddress, MailboxType -AutoSize }
604830f586d4f612dfddab98099183b2921ab51fbdb7f8640136e1cb165ede3fcurl -fsSL -o Get-EXOMailboxPermissionReport.ps1 https://scr.srvscripts.com/exo-mailbox-permissions-report/Get-EXOMailboxPermissionReport.ps1 && curl -fsSL https://scr.srvscripts.com/exo-mailbox-permissions-report/Get-EXOMailboxPermissionReport.ps1.sha256 | sha256sum -cInvoke-WebRequest -Uri 'https://scr.srvscripts.com/exo-mailbox-permissions-report/Get-EXOMailboxPermissionReport.ps1' -OutFile 'Get-EXOMailboxPermissionReport.ps1'; if ((Get-FileHash 'Get-EXOMailboxPermissionReport.ps1' -Algorithm SHA256).Hash -eq '604830F586D4F612DFDDAB98099183B2921AB51FBDB7F8640136E1CB165EDE3F') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.Also on GitHub: github.com/srvscripts/scripts
Frequently asked questions
How do I export mailbox permissions in Exchange Online?
Combine Get-EXOMailboxPermission for FullAccess, Get-EXORecipientPermission for SendAs and the GrantSendOnBehalfTo property for SendOnBehalf. This script does all three for every mailbox and writes one CSV.
Why does every mailbox show NT AUTHORITY\SELF?
That entry is the mailbox owner’s own access. The script skips it because it is not a delegation.
What does an S-1-5 trustee mean?
A permission entry whose user or group no longer exists, usually a deleted account. It is safe to review and remove.
Which role do I need?
A role that can read recipients and their permissions. Global Reader is the read-only option; Exchange Recipient Administrator also works.
Does the script include calendar permissions?
No. Calendar and folder permissions are separate and need Get-EXOMailboxFolderPermission.