Sure, you can click through Entra ID, search for the user, open the membership tab, scroll, copy, paste, repeat. That works for one user. It falls apart the moment someone hands you a list of UPNs and asks for a spreadsheet.
I recently ran into exactly that scenario and ended up writing a PowerShell script that does the heavy lifting for me. It takes an array of user principal names, queries Microsoft Graph, and exports every group membership to a CSV with one row per user per group.
This post walks through what the script does, why it is built the way it is, and how you can use it yourself.
The problem: group membership at scale
In real-world Microsoft 365 tenants:
- Users are often members of dozens or hundreds of groups
- Many of those memberships are inherited through nested groups
- Auditors, security teams, and app owners almost always want the data in Excel
What we want is:
- A simple input, just a list of UPNs
- A reliable way to capture group memberships
- A clean CSV that can be filtered, sorted, and shared
Most importantly, the output needs to reflect reality. If a user is a member of a group through nesting, that should show up. Otherwise the data is misleading.
Why Microsoft Graph (and not AzureAD)
If you are still using the AzureAD or MSOnline modules, this is a good time to stop.
Those modules are deprecated, and their behavior around group membership can already be inconsistent in modern tenants. Microsoft Graph is the supported path forward, and it gives us two key capabilities that matter here:
- Direct group membership
- Transitive (nested) group membership
That second one is the reason many scripts return “zero results” even when the user is clearly in multiple groups.
What this script does
At a high level, the script:
- Accepts an array of user principal names
- Connects to Microsoft Graph with the required permissions
- Retrieves each user’s group memberships
- Expands the results so each group gets its own row
- Exports everything to a timestamped CSV in your Downloads folder
The CSV includes the following columns:
- UPN
- User Display Name
- Group Name
- Group ID
Users will appear multiple times in the file if they are members of multiple groups. That is intentional and exactly what you want for reporting.
Required permissions
When connecting interactively, the account running the script needs:
- User.Read.All
- GroupMember.Read.All
If your tenant restricts consent, you may need an Entra ID admin to grant these permissions once. After that, sign-in is straightforward.
The script
Here is the full script, ready to run. Replace the sample UPNs with your own.
# ---------------------------
# CONFIG
# ---------------------------
$UserPrincipalNames = @(
"user1@contoso.com",
"user2@contoso.com"
)
# Set to $true to include nested group memberships
$UseTransitiveMembership = $true
# ---------------------------
# OUTPUT PATH (Downloads + timestamp)
# ---------------------------
$Downloads = Join-Path ([Environment]::GetFolderPath('UserProfile')) "Downloads"
$Timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
$OutputCsv = Join-Path $Downloads "UserGroupMemberships_$Timestamp.csv"
# ---------------------------
# CONNECT TO GRAPH
# ---------------------------
Import-Module Microsoft.Graph.Users
Import-Module Microsoft.Graph.Groups
Import-Module Microsoft.Graph.DirectoryObjects
Disconnect-MgGraph -ErrorAction SilentlyContinue
Connect-MgGraph -Scopes "User.Read.All","GroupMember.Read.All"
# ---------------------------
# PROCESS USERS
# ---------------------------
$results = New-Object System.Collections.Generic.List[object]
foreach ($upn in $UserPrincipalNames) {
try {
$user = Get-MgUser -UserId $upn -Property "id,displayName,userPrincipalName" -ErrorAction Stop
}
catch {
Write-Warning "User not found or not accessible: $upn"
continue
}
try {
if ($UseTransitiveMembership) {
$memberOf = Get-MgUserTransitiveMemberOf -UserId $user.Id -All
} else {
$memberOf = Get-MgUserMemberOf -UserId $user.Id -All
}
}
catch {
Write-Warning "Failed to get memberships for $upn"
continue
}
$groups = $memberOf | Where-Object {
$_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group'
}
foreach ($g in $groups) {
$groupName = $g.AdditionalProperties['displayName']
if (-not $groupName) {
$fullGroup = Get-MgGroup -GroupId $g.Id -Property displayName -ErrorAction SilentlyContinue
$groupName = $fullGroup.DisplayName
}
$results.Add([pscustomobject]@{
"UPN" = $user.UserPrincipalName
"User Display Name" = $user.DisplayName
"Group Name" = $groupName
"Group ID" = $g.Id
})
}
}
# ---------------------------
# EXPORT CSV
# ---------------------------
$results |
Sort-Object UPN, "Group Name" |
Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8
Write-Host "Exported $($results.Count) rows to $OutputCsv"
Direct vs transitive membership (why this matters)
If you only use Get-MgUserMemberOf, you will get direct group memberships only. In many tenants, that returns nothing useful because users are added to one group, which is then nested into others.
By switching to Get-MgUserTransitiveMemberOf, the script captures the full picture. This is almost always what auditors and security teams expect, even if they do not use the term “transitive.”
If you want only direct memberships, set $UseTransitiveMembership to $false.
Why the CSV is timestamped
The script saves the file to your Downloads folder with a date and time in the filename. This solves two problems:
- You can run the script multiple times without overwriting previous exports
- You can prove when the data was captured, which matters for audits
It also makes it easy to keep a “point in time” record of group membership changes.
Final thoughts
This is one of those scripts that pays for itself quickly. Once you have it, you stop clicking through portals and start answering questions in minutes instead of hours.
If you support identity, security, or access reviews in Microsoft 365, this belongs in your toolbox.
If you want to extend it further, good next steps are:
- Adding group type (Security vs Microsoft 365)
- Including directory roles
- Switching to app-only authentication for automation
For now, though, this script gets you accurate, repeatable results with minimal effort.
