All scripts
Governance 421

Microsoft 365 Group Expiration Report

A Microsoft Graph script that reports on Microsoft 365 Groups nearing their expiration date, and flags any that have no owner left to receive the renewal notice.

Get-M365GroupExpirationReport.ps1
<#
.SYNOPSIS
    Reports on Microsoft 365 Groups that are approaching or past their expiration date.

.DESCRIPTION
    Connects to Microsoft Graph and pulls every Microsoft 365 Group covered by a
    group lifecycle policy, then reports how many days are left before each one
    expires or is renewed. Useful for catching groups that are about to be
    deleted with nobody watching, or groups where the last owner left the
    company and renewal emails are going nowhere.

.PARAMETER DaysUntilExpiration
    Only show groups expiring within this many days. Defaults to 30.

.PARAMETER ExportPath
    Optional path to export the results as a CSV file.

.EXAMPLE
    .\Get-M365GroupExpirationReport.ps1 -DaysUntilExpiration 30

.EXAMPLE
    .\Get-M365GroupExpirationReport.ps1 -DaysUntilExpiration 60 -ExportPath C:\Reports\GroupExpiration.csv

.AUTHOR
    Shehryar Hassan
#>

[CmdletBinding()]
param(
    [int]$DaysUntilExpiration = 30,
    [string]$ExportPath
)

if (-not (Get-MgContext)) {
    Connect-MgGraph -Scopes "Group.Read.All", "Directory.Read.All"
}

Write-Host "Pulling Microsoft 365 Groups with expiration data..." -ForegroundColor Cyan

$groups = Get-MgGroup -All -Property "Id,DisplayName,Mail,ExpirationDateTime,RenewedDateTime,GroupTypes" |
    Where-Object { $_.GroupTypes -contains "Unified" -and $_.ExpirationDateTime }

if (-not $groups) {
    Write-Host "No groups with an expiration date were found. Check that a group lifecycle policy is configured in the Entra admin center." -ForegroundColor Yellow
    return
}

$today = Get-Date
$results = foreach ($group in $groups) {
    $expires = [datetime]$group.ExpirationDateTime
    $daysLeft = [math]::Round(($expires - $today).TotalDays)

    if ($daysLeft -le $DaysUntilExpiration) {
        $owners = Get-MgGroupOwner -GroupId $group.Id -All
        $ownerNames = if ($owners) {
            ($owners | ForEach-Object { $_.AdditionalProperties["displayName"] }) -join "; "
        }
        else {
            "NO OWNERS"
        }

        [PSCustomObject]@{
            GroupName   = $group.DisplayName
            Mail        = $group.Mail
            ExpiresOn   = $expires.ToString("yyyy-MM-dd")
            DaysLeft    = $daysLeft
            LastRenewed = if ($group.RenewedDateTime) { ([datetime]$group.RenewedDateTime).ToString("yyyy-MM-dd") } else { "Never" }
            Owners      = $ownerNames
            Status      = if ($daysLeft -lt 0) { "EXPIRED" } elseif ($ownerNames -eq "NO OWNERS") { "AT RISK, NO OWNER" } else { "EXPIRING SOON" }
        }
    }
}

$results = $results | Sort-Object DaysLeft

if (-not $results) {
    Write-Host "No groups are expiring within the next $DaysUntilExpiration days." -ForegroundColor Green
    return
}

$results | Format-Table -AutoSize

$atRisk = $results | Where-Object { $_.Status -eq "AT RISK, NO OWNER" }
if ($atRisk) {
    Write-Host ""
    Write-Host "$($atRisk.Count) group(s) are expiring soon with no owner to receive the renewal notice. Fix ownership on these first." -ForegroundColor Red
}

if ($ExportPath) {
    $results | Export-Csv -Path $ExportPath -NoTypeInformation
    Write-Host "Exported to $ExportPath" -ForegroundColor Cyan
}

Read it before you run it, and test in a safe tenant first.