Microsoft 365 & Entra ID Automation Runbook: PowerShell, Graph API & FinOps
What is Microsoft 365 PowerShell Automation? Microsoft 365 PowerShell automation uses the Microsoft Graph PowerShell SDK and Azure Automation Managed Identities to eliminate manual sysadmin tasks across Entra ID and Exchange Online. It standardizes zero-trust onboarding, instant 7-step offboarding, and automated FinOps license reclamation without storing passwords or expiring client secrets.
Enterprise IT organizations managing hundreds or thousands of Microsoft 365 seats face two continuous operational bottlenecks: human error in security offboarding and license expenditure waste (FinOps).
When an employee departs, a delayed deprovisioning or missed token revocation leaves a dangerous security gap for credential theft. Simultaneously, companies spend tens of thousands of dollars annually paying for premium Microsoft 365 E5 and E3 licenses assigned to inactive users, contractors who left months ago, or shared mailboxes that do not require paid licensing.
In this comprehensive production runbook, we walk through building an autonomous enterprise lifecycle engine using Microsoft Graph PowerShell SDK v2 and Azure Automation. This architecture eliminates legacy basic authentication, discards fragile service accounts, and operates securely using cloud-native System-Assigned Managed Identities.
Architecture: Zero-Secret Azure Automation with Managed Identities
Legacy automation scripts relied on storing administrator credentials in cleartext or managing client secrets in Entra App Registrations that inevitably expire, breaking production scripts on weekends.
Modern enterprise automation uses an Azure Automation Account with a System-Assigned Managed Identity:
Required Microsoft Graph API Application Roles
Before executing runbooks, assign the following least-privilege Graph permissions to the Automation Account’s Managed Identity:
| Microsoft Graph Role | Type | Justification |
|---|---|---|
User.ReadWrite.All |
Application | Provision new accounts, update attributes, and disable accounts upon termination. |
Directory.ReadWrite.All |
Application | Manage dynamic/assigned group memberships and security attributes. |
AuditLog.Read.All |
Application | Inspect signInActivity (lastSignInDateTime) to locate inactive accounts for FinOps. |
Organization.Read.All |
Application | Query available tenant license SKUs and consumption quotas. |
PowerShell Command to Grant Permissions to Managed Identity:
# Connect to Microsoft Graph with Privileged Role Administrator
Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All", "Application.Read.All"
$AutomationAccountName = "aa-m365-production-automation"
$GraphAppId = "00000003-0000-0000-c000-000000000000" # Well-known Microsoft Graph Resource App ID
# Locate the Managed Identity Service Principal
$SpManagedIdentity = Get-MgServicePrincipal -Filter "displayName eq '$AutomationAccountName'"
$GraphSp = Get-MgServicePrincipal -Filter "appId eq '$GraphAppId'"
$RolesToGrant = @(
"User.ReadWrite.All",
"Directory.ReadWrite.All",
"AuditLog.Read.All",
"Organization.Read.All"
)
foreach ($RoleName in $RolesToGrant) {
$AppRole = $GraphSp.AppRoles | Where-Object { $_.Value -eq $RoleName -and $_.AllowedMemberTypes -contains "Application" }
New-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $SpManagedIdentity.Id `
-PrincipalId $SpManagedIdentity.Id `
-ResourceId $GraphSp.Id `
-AppRoleId $AppRole.Id
Write-Host "[+] Granted $RoleName to $($SpManagedIdentity.DisplayName)" -ForegroundColor Green
}
1. Automated Zero-Trust Employee Offboarding (7-Step Security Protocol)
Manual offboarding via the Microsoft 365 Admin Center typically takes 15–25 minutes per user and is prone to human error—such as forgetting to convert a mailbox to shared or leaving active refresh tokens alive on mobile devices.
This automated PowerShell script executes a strict 7-step zero-trust deprovisioning flow in under 8 seconds:
- Instant Session Revocation: Invalidate all active OAuth tokens and mobile sessions via
Revoke-MgUserSignOut. - Account Disablement: Set
AccountEnabled = falseand scramble password. - Mailbox Conversion: Convert to Shared Mailbox via
Set-Mailbox -Type Shared(retaining 50 GB storage with zero license cost). - Legal Discovery Hold: Apply Litigation Hold or Purview retention.
- Departure Auto-Reply: Activate external out-of-office message redirecting clients.
- Security Group Purge: Remove from all security and Microsoft 365 groups.
- FinOps License Reclamation: De-allocate premium E5/E3 license SKUs back to tenant pool.
Production PowerShell Offboarding Script:
<#
.SYNOPSIS
Automated Zero-Trust Offboarding Protocol for Entra ID & Microsoft 365.
.DESCRIPTION
Revokes sessions, disables sign-in, converts mailbox to Shared,
strips paid license SKUs, and removes sensitive group memberships.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$UserPrincipalName,
[Parameter(Mandatory = $false)]
[string]$AutoReplyMessage = "This employee has parted ways with the company. Please direct all inquiries to support@yourdomain.com."
)
# Connect via Managed Identity inside Azure Automation
try {
Connect-MgGraph -Identity -NoWelcome
Write-Host "[+] Connected to Microsoft Graph via System-Assigned Managed Identity." -ForegroundColor Cyan
} catch {
Write-Error "Failed to authenticate to Microsoft Graph: $_"
exit 1
}
# 1. Fetch Target User
$User = Get-MgUser -UserId $UserPrincipalName -Property Id, DisplayName, UserPrincipalName, AssignedLicenses, AccountEnabled
if (-not $User) {
Write-Error "User $UserPrincipalName not found in Entra ID."
exit 1
}
Write-Host ">>> Commencing Zero-Trust Offboarding for: $($User.DisplayName) ($($User.UserPrincipalName))" -ForegroundColor Yellow
# STEP 1: Instant Session Revocation (Invalidates Refresh Tokens & Mobile Access)
try {
Revoke-MgUserSignOut -UserId $User.Id | Out-Null
Write-Host "[1/7] Successfully revoked all active Entra ID tokens and OAuth sessions." -ForegroundColor Green
} catch {
Write-Warning "Could not revoke sign-out tokens: $_"
}
# STEP 2: Disable Entra ID Account & Reset Password
try {
$RandomPassword = [System.Web.Security.Membership]::GeneratePassword(24, 4)
Update-MgUser -UserId $User.Id -AccountEnabled:$false -PasswordProfile @{
ForceChangePasswordNextSignIn = $false
Password = $RandomPassword
}
Write-Host "[2/7] Account disabled and credential scrambled." -ForegroundColor Green
} catch {
Write-Error "Failed to disable account: $_"
}
# STEP 3 & 4: Exchange Online Mailbox Conversion & Retention Hold
try {
# Convert to Shared Mailbox (Zero-license cost storage up to 50 GB)
Set-Mailbox -Identity $User.UserPrincipalName -Type Shared -ErrorAction Stop
Write-Host "[3/7] Mailbox converted to Shared Mailbox (Zero-license cost storage)." -ForegroundColor Green
# Enable Litigation Hold to preserve legal discovery records
Set-Mailbox -Identity $User.UserPrincipalName -LitigationHoldEnabled $true -ErrorAction SilentlyContinue
Write-Host "[4/7] Litigation Hold verified active." -ForegroundColor Green
# STEP 5: Set Out of Office Auto-Reply
Set-MailboxAutoReplyConfiguration -Identity $User.UserPrincipalName `
-AutoReplyState Enabled `
-InternalMessage $AutoReplyMessage `
-ExternalMessage $AutoReplyMessage `
-ErrorAction SilentlyContinue
Write-Host "[5/7] Out of Office departure notice activated." -ForegroundColor Green
} catch {
Write-Warning "Exchange Online task skipped or mailbox not present: $_"
}
# STEP 6: Remove Sensitive Group Memberships
try {
$MemberGroups = Get-MgUserMemberOf -UserId $User.Id -All | Where-Object { $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group' }
foreach ($Group in $MemberGroups) {
Remove-MgGroupMemberByRef -GroupId $Group.Id -DirectoryObjectId $User.Id -ErrorAction SilentlyContinue
}
Write-Host "[6/7] Removed user from $($MemberGroups.Count) security and Microsoft 365 groups." -ForegroundColor Green
} catch {
Write-Warning "Group removal encountered an issue: $_"
}
# STEP 7: Strip Paid Licenses (FinOps Cost Reclaim)
try {
if ($User.AssignedLicenses.Count -gt 0) {
$LicensesToRemove = $User.AssignedLicenses.SkuId
Set-MgUserLicense -UserId $User.Id -AddLicenses @() -RemoveLicenses $LicensesToRemove -ErrorAction Stop
Write-Host "[7/7] Successfully stripped $($LicensesToRemove.Count) paid license SKU(s). License returned to tenant pool!" -ForegroundColor Green
} else {
Write-Host "[7/7] User had no direct license SKUs assigned." -ForegroundColor Gray
}
} catch {
Write-Error "Failed to reclaim license SKUs: $_"
}
Write-Host ">>> Offboarding process completed successfully for $($User.UserPrincipalName)." -ForegroundColor Cyan
2. FinOps License Hunter: Automatically Detecting & Reclaiming Inactive Seats
One of the largest hidden drains on enterprise IT budgets is paying for premium licenses ($22 to $38/seat/month for Microsoft 365 Business Premium, E3, or E5) assigned to accounts that have not authenticated for 90+ days.
Microsoft Graph exposes the signInActivity property on user objects, revealing the exact timestamp of interactive and non-interactive logins.
Inactive User Hunter Script:
<#
.SYNOPSIS
Identifies licensed users inactive for >90 days and exports financial reclamation estimates.
#>
param (
[int]$InactiveDaysThreshold = 90,
[switch]$AutoDeallocateLicenses = $false
)
Connect-MgGraph -Identity -NoWelcome
$CutoffDate = (Get-Date).AddDays(-$InactiveDaysThreshold)
Write-Host ">>> Scanning for licensed users inactive since: $($CutoffDate.ToString('yyyy-MM-dd'))" -ForegroundColor Cyan
# Fetch all licensed users with their last signInActivity
$LicensedUsers = Get-MgUser -All -Filter "assignedLicenses/`$count ne 0" -ConsistencyLevel eventual `
-Property Id, DisplayName, UserPrincipalName, Mail, UserType, AssignedLicenses, SignInActivity
$OrphanedUsers = @()
$EstimatedMonthlyWaste = 0
foreach ($User in $LicensedUsers) {
if ($User.UserType -eq "Guest") { continue }
$LastSignIn = $User.SignInActivity.LastSignInDateTime
$LastNonInteractive = $User.SignInActivity.LastNonInteractiveSignInDateTime
$LatestActivity = $null
if ($LastSignIn -and $LastNonInteractive) {
$LatestActivity = if ($LastSignIn -gt $LastNonInteractive) { $LastSignIn } else { $LastNonInteractive }
} elseif ($LastSignIn) {
$LatestActivity = $LastSignIn
} elseif ($LastNonInteractive) {
$LatestActivity = $LastNonInteractive
}
if (-not $LatestActivity -or ($LatestActivity -lt $CutoffDate)) {
$EstimatedCost = ($User.AssignedLicenses.Count * 25)
$EstimatedMonthlyWaste += $EstimatedCost
$OrphanedUsers += [PSCustomObject]@{
DisplayName = $User.DisplayName
UserPrincipalName = $User.UserPrincipalName
LastActive = if ($LatestActivity) { $LatestActivity.ToString("yyyy-MM-dd") } else { "Never Logged In" }
LicensesCount = $User.AssignedLicenses.Count
MonthlySavingsUSD = $EstimatedCost
}
if ($AutoDeallocateLicenses) {
$LicensesToRemove = $User.AssignedLicenses.SkuId
Set-MgUserLicense -UserId $User.Id -AddLicenses @() -RemoveLicenses $LicensesToRemove -ErrorAction SilentlyContinue
Write-Host "[-] Auto-deallocated license from $($User.UserPrincipalName)" -ForegroundColor Yellow
}
}
}
Write-Host "`n=== FINOPS RECLAMATION AUDIT SUMMARY ===" -ForegroundColor Green
Write-Host "Total Inactive Licensed Users Found: $($OrphanedUsers.Count)"
Write-Host "Estimated Monthly Wasted Spend: `$$($EstimatedMonthlyWaste) / month"
Write-Host "Estimated Annual Recurring Savings: `$$($EstimatedMonthlyWaste * 12) / year`n"
$OrphanedUsers | Format-Table -AutoSize
3. Automated Onboarding & Dynamic Group Assignment
Manual account creation frequently causes missing phone numbers, mismatched Department strings, and forgotten security group assignments.
By standardizing onboarding around Microsoft Graph SDK, you can execute user creation, automated usage location assignment (mandatory for licensing), and license allocation in one reproducible transaction:
<#
.SYNOPSIS
Automated User Provisioning with Usage Location and License Allocation.
#>
param (
[Parameter(Mandatory = $true)][string]$FirstName,
[Parameter(Mandatory = $true)][string]$LastName,
[Parameter(Mandatory = $true)][string]$Department,
[Parameter(Mandatory = $true)][string]$JobTitle,
[Parameter(Mandatory = $true)][string]$UsageLocation = "IN",
[Parameter(Mandatory = $true)][string]$Domain = "yourdomain.com",
[Parameter(Mandatory = $true)][string]$LicenseSkuId
)
Connect-MgGraph -Identity -NoWelcome
$UPN = "$($FirstName.ToLower()).$($LastName.ToLower())@$Domain"
$DisplayName = "$FirstName $LastName"
$InitialPassword = "P@ssw0rd!" + (Get-Random -Minimum 1000 -Maximum 9999) + "#"
$PasswordProfile = @{
Password = $InitialPassword
ForceChangePasswordNextSignIn = $true
}
$NewUserParams = @{
DisplayName = $DisplayName
GivenName = $FirstName
Surname = $LastName
UserPrincipalName = $UPN
MailNickname = "$($FirstName.ToLower()).$($LastName.ToLower())"
Department = $Department
JobTitle = $JobTitle
UsageLocation = $UsageLocation
AccountEnabled = $true
PasswordProfile = $PasswordProfile
}
try {
$CreatedUser = New-MgUser @NewUserParams -ErrorAction Stop
Write-Host "[+] User created successfully: $($CreatedUser.UserPrincipalName) (ID: $($CreatedUser.Id))" -ForegroundColor Green
Start-Sleep -Seconds 3
Set-MgUserLicense -UserId $CreatedUser.Id `
-AddLicenses @(@{ SkuId = [Guid]$LicenseSkuId }) `
-RemoveLicenses @() `
-ErrorAction Stop
Write-Host "[+] License SKU assigned. Temporary password: $InitialPassword" -ForegroundColor Green
} catch {
Write-Error "Failed to provision new user: $_"
}
4. Microsoft Teams Webhook & Audit Logging
To maintain audit compliance under ITIL and ISO 27001, your automated runbooks must generate real-time alerts whenever a user is provisioned or offboarded.
function Send-TeamsLifecycleNotification {
param (
[string]$WebhookUrl,
[string]$EventTitle,
[string]$UserUPN,
[string]$ActionDetails,
[string]$StatusColor = "0076D7"
)
$Payload = @{
"@type" = "MessageCard"
"@context" = "http://schema.org/extensions"
"themeColor" = $StatusColor
"summary" = $EventTitle
"sections" = @(
@{
"activityTitle" = "⚡ **$EventTitle**"
"activitySubtitle" = "Microsoft 365 Autonomous Runbook Telemetry"
"facts" = @(
@{ "name" = "Target UPN:"; "value" = $UserUPN },
@{ "name" = "Timestamp:"; "value" = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss UTC") },
@{ "name" = "Actions Taken:"; "value" = $ActionDetails }
)
"markdown" = $true
}
)
} | ConvertTo-Json -Depth 4
Invoke-RestMethod -Uri $WebhookUrl -Method Post -Body $Payload -ContentType 'application/json' | Out-Null
Write-Host "[+] Teams audit notification dispatched." -ForegroundColor Green
}
Common Enterprise Pitfalls & Solutions
1. Hybrid Active Directory vs. Cloud-Only Accounts
Update-MgUser -AccountEnabled:$false directly in Microsoft Graph will be overwritten on the next sync cycle. For hybrid tenants, account disabling must be performed on-premises, while session revocation and license stripping occur in the cloud.
For a full architectural comparison, read our guide on Microsoft Entra Connect vs. Cloud Sync Migration.
2. Graph API Rate Limiting (HTTP 429 Too Many Requests)
When scanning thousands of users for signInActivity, Microsoft Graph enforces tenant throttling limits. Always implement exponential backoff retry logic in production runbooks.
Frequently Asked Questions
Why use Managed Identities instead of App Registrations with client secrets?
Client secrets expire after a maximum of 24 months, causing production jobs to fail abruptly. Managed Identities have no passwords or credentials to rotate, meeting strict zero-trust compliance standards.
What happens to a user’s OneDrive files during automated offboarding?
When an Entra ID user is disabled, OneDrive retains files for 30 days by default (configurable up to 3,650 days). Before stripping licenses, the runbook can delegate secondary site collection access to the direct manager.
Does converting to a Shared Mailbox require an Exchange license?
No. Shared mailboxes with up to 50 GB storage do not require a paid Exchange Online license, allowing companies to preserve communication history at zero subscription cost.
