maik.ing | the terminal garden
Building, Breaking, and Automating.
Welcome to maik.ing. This is my digital toolbox for everything related to Microsoft 365, PowerShell automation, and cloud infrastructure. Here, I share functional scribbles, in-depth M365 insights, and the occasional Nordic breeze from my everyday IT life.
Subscribe via RSS here

Defusing the Time Bomb: Automating Entra ID App Secret Expiry Alerts

Welcome back to the terminal garden.

Every M365 admin knows this scenario: It’s a quiet Friday afternoon. Suddenly, your helpdesk lights up. A critical HR synchronization script has stopped working, your automated backup pipeline is throwing unauthorized errors, or a third-party API integration just died. You spend an hour digging through logs only to discover the most mundane root cause possible: An Entra ID App Registration client secret quietly expired.

Entra ID enforces a maximum lifespan for client secrets and certificates, but it is notoriously bad at proactively warning the right people. Let's build a serverless early warning system using Azure Automation, Microsoft Graph, and a Teams Workflow.

The Serverless Setup & Costs

We do not want to run this script manually, and we definitely do not want to store a service account password inside the script. The most elegant solution is Azure Automation with a System Assigned Managed Identity.

πŸ’‘ Good to Know: What does this cost?
If you have never used Azure Automation before, don't worry about the bill. Microsoft includes 500 minutes of free job run time per month. This script takes about 15–30 seconds to execute. Even if you run it daily, you will consume less than 15 minutes a month. The Managed Identity and the Teams Workflow are also included in standard subscriptions. Total cost: 0.00 € / month.

Step 1: Create the Automation Account & Grant Permissions

  1. In the Azure Portal, create an Automation Account, and ensure System assigned managed identity is checked.
  2. The Azure Portal lacks a GUI to grant Microsoft Graph permissions to this identity. Run this locally on your PC (requires Admin rights):

PowerShell

Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All", "Application.Read.All"

$ManagedIdentityName = "YOUR-AUTOMATION-ACCOUNT-NAME" 
$PermissionName = "Application.Read.All" $MSI = Get-MgServicePrincipal -Filter "displayName eq '$ManagedIdentityName'"
$Graph = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" $Role = $Graph.AppRoles | Where-Object Value -eq $PermissionName
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $MSI.Id -PrincipalId $MSI.Id -ResourceId $Graph.Id -AppRoleId $Role.Id

Step 2: Import the Modules (The Dependency Trap)

Do not just import Microsoft.Graph.Applications! The Connect-MgGraph cmdlet and core authentication layers live in the base module. If you skip it, your runbook will crash with a missing cmdlet error.

  • In your Automation Account, go to Modules -> Add a module.
  • Import Microsoft.Graph.Authentication first.
  • Import Microsoft.Graph.Applications second.

The Prerequisites: Setting up the Teams Webhook

Microsoft retired the classic "Incoming Webhooks" in 2024. The modern way is via Teams Workflows:

  • Open Teams, go to your IT channel, click ... and select Workflows.
  • Search for the template: Post to a channel when a webhook request is received.
  • Name it, select your channel, click Add workflow, and copy the generated URL.

The Runbook Code

Create a new PowerShell Runbook in your Automation Account, paste this code, and link it to a weekly schedule.

PowerShell

# =====================================================================
# maik.ing | the terminal garden - App Secret Expiry Alert
# =====================================================================
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Applications # 1. Configuration
$TeamsWebhookUrl = "YOUR_TEAMS_WORKFLOW_URL_HERE"
$DaysThreshold = 30
$CurrentDate = Get-Date # 2. Connect via Managed Identity
Connect-MgGraph -Identity
$Apps = Get-MgApplication -All
$ExpiringItems = @() # 3. Check Secrets and Certificates
foreach ($App in $Apps) {
# Check Client Secrets
foreach ($Secret in $App.PasswordCredentials) {
if ($null -ne $Secret.EndDateTime) {
$DaysLeft = ($Secret.EndDateTime - $CurrentDate).Days
if ($DaysLeft -ge 0 -and $DaysLeft -le $DaysThreshold) {
$ExpiringItems += [PSCustomObject]@{
AppName = $App.DisplayName; AppId = $App.AppId
Type = "Client Secret"; DaysLeft = $DaysLeft
ExpiryDate = $Secret.EndDateTime.ToString("yyyy-MM-dd")
}
}
}
}

# Check Certificates
foreach ($Cert in $App.KeyCredentials) {
if ($null -ne $Cert.EndDateTime) {
$DaysLeft = ($Cert.EndDateTime - $CurrentDate).Days
if ($DaysLeft -ge 0 -and $DaysLeft -le $DaysThreshold) {
$ExpiringItems += [PSCustomObject]@{
AppName = $App.DisplayName; AppId = $App.AppId
Type = "Certificate"; DaysLeft = $DaysLeft
ExpiryDate = $Cert.EndDateTime.ToString("yyyy-MM-dd")
}
}
}
}
} # 4. Alerting via Teams Adaptive Card
if ($ExpiringItems.Count -gt 0) {
$Message = ""
foreach ($Item in $ExpiringItems) {
# Deep Link directly into the Entra Admin Center
$AppUrl = "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Credentials/appId/$($Item.AppId)/isMSAApp~/false"
$Message += "- **[$($Item.AppName)]($AppUrl)** ($($Item.Type)) expires in **$($Item.DaysLeft) days** ($($Item.ExpiryDate))`n"
} $Payload = @{
type = "AdaptiveCard"
'$schema' = "http://adaptivecards.io/schemas/adaptive-card.json"
version = "1.4"
body = @(
@{ type = "TextBlock"; size = "Large"; weight = "Bolder"; color = "Attention"; text = "🚨 Entra ID App Expiry Warning" },
@{ type = "TextBlock"; text = $Message; wrap = $true }
)
} | ConvertTo-Json -Depth 10 # UTF-8 Encoding Fix: Prevents Emojis from turning into "??" in Azure Automation
$Utf8Payload = [System.Text.Encoding]::UTF8.GetBytes($Payload)
Invoke-RestMethod -Uri $TeamsWebhookUrl -Method Post -Body $Utf8Payload -ContentType "application/json; charset=utf-8"
} Disconnect-MgGraph

The Nordic Breeze Takeaway

Two important lessons learned from building this scribble:

  1. Module dependencies matter in Azure: Sub-modules like Microsoft.Graph.Applications will silently fail or break cmdlets if Microsoft.Graph.Authentication is not imported alongside them.
  2. Mind your payload encoding: When running in cloud sandbox runbooks, always convert your JSON payload explicitly to UTF-8 bytes before sending it to an endpoint. Otherwise, your nice alert emojis will turn into question marks (??).

Set it up once, link it to a weekly schedule, and enjoy quiet Friday afternoons.

β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2

ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9

SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!

Targeted Offsite OneDrive Backups with PnP PowerShell

Welcome back to the terminal garden. Sometimes, amidst the complex cloud infrastructure, you just need a straightforward, targeted offsite backup of a specific user's Microsoft 365 OneDrive. No heavy third-party enterprise tools, just plain PowerShell and a bit of automation logic.

Here is a functional scribble from my digital toolbox to recursively download a user's entire OneDrive structure to a local machine.

The Prerequisites

Before running the backup, Microsoft 365 requires a few specific keys to the kingdom:

  • PnP PowerShell Module: Ensure you have the latest PnP.PowerShell module installed (Install-Module PnP.PowerShell).
  • Permissions: You cannot back up what you cannot see. Even as a Global Admin, you must grant yourself Site Collection Administrator rights to the specific user's OneDrive via the M365 Admin Center before running the script.
  • Entra ID App Registration: Microsoft retired the default PnP multi-tenant app for interactive logins in September 2024. You must register your own minimal App in Entra ID. Fortunately, we can automate that part too.

The Pre-Game: Automating the App Registration

Instead of clicking through the Entra ID portal to set up redirect URIs and API permissions, you can use this quick snippet (requires Global Admin rights). It registers the app, configures http://localhost for the login prompt, grants the necessary SharePoint permissions, and handles the admin consent all in one go.

PowerShell

# =====================================================================
# App Registration Scribble
# =====================================================================
Import-Module PnP.PowerShell $TenantName = "contoso.onmicrosoft.com"
$AppName = "PnP PowerShell Backup" Write-Host "Creating Entra ID App Registration..." -ForegroundColor Cyan
Register-PnPEntraIDAppForInteractiveLogin -ApplicationName $AppName -Tenant $TenantName # NOTE: Grab the "Client ID" from the console output once this finishes!

The Main Event: The Backup Script

Once you have your Client ID from the step above, you can run the actual backup. This script connects to the target OneDrive, checks for the standard "Documents" library, and recursively downloads all files and folders while maintaining the original directory structure.

PowerShell

# =====================================================================
# maik.ing | the terminal garden - OneDrive Offsite Backup
# =====================================================================
Import-Module PnP.PowerShell # 1. Define Variables
$UserUPN = "jane.doe@contoso.com"
$Tenant = "contoso"
$BasePath = "C:\LocalBackups\OneDrive"
$BackupDir = Join-Path -Path $BasePath -ChildPath $UserUPN
$ClientId = "YOUR-ENTRA-ID-CLIENT-ID-HERE" # Insert the ID from the pre-game script # Format URL (OneDrive URLs replace dots and @ with underscores)
$UserUrlPart = $UserUPN.Replace(".", "_").Replace("@", "_")
$OneDriveUrl = "https://$Tenant-my.sharepoint.com/personal/$UserUrlPart" # 2. Recursive Download Function
Function Backup-OneDriveFolder {
param (
[Parameter(Mandatory=$true)] $FolderUrl,
[Parameter(Mandatory=$true)] $TargetFolder
)

if (-not (Test-Path $TargetFolder)) {
New-Item -ItemType Directory -Path $TargetFolder -Force | Out-Null
} # Download files
$files = Get-PnPFolderItem -FolderSiteRelativeUrl $FolderUrl -ItemType File
foreach ($file in $files) {
Write-Host " -> Downloading: $($file.Name)" -ForegroundColor Gray
Get-PnPFile -Url $file.ServerRelativeUrl -Path $TargetFolder -FileName $file.Name -AsFile -Force
} # Process subfolders
$subFolders = Get-PnPFolderItem -FolderSiteRelativeUrl $FolderUrl -ItemType Folder
foreach ($subFolder in $subFolders) {
# Ignore hidden SharePoint system folders
if ($subFolder.Name -notin @("Forms", "Hidden", "_t", "_w")) {
$newTargetFolder = Join-Path -Path $TargetFolder -ChildPath $subFolder.Name
Backup-OneDriveFolder -FolderUrl "$FolderUrl/$($subFolder.Name)" -TargetFolder $newTargetFolder
}
}
} # 3. Connect and Execute
Write-Host "Connecting to OneDrive for $UserUPN..." -ForegroundColor Cyan
Connect-PnPOnline -Url $OneDriveUrl -Interactive -ClientId $ClientId # Access Check
$docList = Get-PnPList -Identity "Documents" -ErrorAction SilentlyContinue
if ($null -ne $docList) {
Write-Host "Success! Found $($docList.ItemCount) items on the server." -ForegroundColor Green
Write-Host "Starting backup to $BackupDir ..." -ForegroundColor Yellow
Backup-OneDriveFolder -FolderUrl "Documents" -TargetFolder $BackupDir
Write-Host "Backup completed!" -ForegroundColor Green
} else {
Write-Host "ERROR: Library is empty or you lack Site Collection Admin rights!" -ForegroundColor Red
} Disconnect-PnPOnline

The Nordic Breeze Takeaway

The built-in access check in step 3 is a lifesaver. PnP PowerShell will happily establish a connection to a OneDrive site even if your specific admin account lacks file-level access. Without the check, the script simply sees an "empty" drive and finishes instantly. Always verify your access first before trusting a "completed" backup!


β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2
ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9
SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!

Searching SharePoint Programmatically Without Breaking the Permission Model

Building, Breaking, and Automating.

Sometimes the problem is not that a document does not exist.

The problem is that nobody knows where it is.

A recent request was a good example: years of historical contracts and documents were spread across departmental folders, subfolders, old structures, and SharePoint libraries. The SharePoint UI worked well enough for normal day-to-day browsing, but not particularly well for finding a specific document somewhere inside years of accumulated content.

The question was simple:

Can we search SharePoint programmatically without giving the application more access than the user already has?

Yes.

And the important part is not the search API.

It is the authentication model.


The Requirement

The goal was to build a small tool that could:

  • search SharePoint documents programmatically;
  • search across folders and subfolders;
  • use filenames, metadata, and indexed document content;
  • return direct links to matching documents;
  • remain read-only;
  • and, most importantly, never expose documents the authenticated user could not already access.

The environment made this slightly more interesting because the documents were not neatly isolated inside a dedicated Finance SharePoint site.

Multiple departments shared parts of the same SharePoint structure.

That immediately ruled out one tempting design.


The Wrong Approach: App-Only Access

My first instinct with Microsoft Graph integrations is often an Entra application using client credentials:

Application
|
| Client ID + Secret / Certificate
v
Microsoft Graph
|
v
SharePoint

That works extremely well for unattended automation.

But it was the wrong security model here.

With the OAuth client credentials flow, there is no user in the access token.

The question Graph effectively answers is:

Is the application allowed to access this document?

not:

Is the currently logged-in employee allowed to access this document?

For a search tool used interactively by employees, that distinction matters a lot.

Broad application permissions such as:

Files.Read.All
Sites.Read.All
Sites.FullControl.All

can give an application access independently of the individual user's SharePoint permissions.

Even Sites.Selected, while much safer for app-only scenarios, was not quite what I wanted here. It is useful when an application should have access to a specific SharePoint site, but the requirement was different:

Use the permissions of whoever is currently using the tool.

That points directly to delegated permissions.


Delegated Access

The final model looks like this:

User
|
| Interactive Microsoft 365 Login
v
Microsoft Entra ID
|
| Delegated Access Token
v
Microsoft Graph
|
v
SharePoint

The application uses a delegated permission such as:

Files.Read.All

The important word here is:

Delegated

In this configuration, Graph performs the operation in the context of the signed-in user.

So the effective authorization becomes:

Can the user access the document?
|
+--+--+
| |
Yes No
| |
return hide / deny

This is exactly what I wanted.

The tool does not become a privileged SharePoint crawler.

It becomes another interface through which the user exercises their existing permissions.


Creating the Entra Application

The application itself is intentionally simple.

I registered a new single-tenant application in Microsoft Entra ID.

Conceptually:

Supported account types:
Single tenant Platform:
Mobile and desktop applications Redirect URI:
http://localhost Microsoft Graph:
Files.Read.All Delegated
User.Read Delegated

No client secret is required.

That is another nice property of this architecture.

The user authenticates interactively through Microsoft's login flow, so there is no application password sitting inside a Python script.


Authentication with Python and MSAL

For the prototype I used Python together with Microsoft's MSAL library.

Install the dependencies:

python -m pip install msal requests

The authentication code is tiny:

import msal

CLIENT_ID = "<client-id>"
TENANT_ID = "<tenant-id>" AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" SCOPES = [
"Files.Read.All",
"User.Read",
] app = msal.PublicClientApplication(
CLIENT_ID,
authority=AUTHORITY,
) result = app.acquire_token_interactive(
scopes=SCOPES
) if "access_token" not in result:
print(result)
raise SystemExit("Authentication failed") access_token = result["access_token"]

Running the script opens the normal Microsoft authentication flow.

MFA and existing Conditional Access policies continue to apply.

No password handling.

No client secret.

No custom authentication.

Exactly how I like it.


First Test: Who Am I?

Before touching SharePoint, I always like to verify the identity behind the token.

Graph makes that trivial:

GET https://graph.microsoft.com/v1.0/me

Using Python:

import requests

response = requests.get(
"https://graph.microsoft.com/v1.0/me",
headers={
"Authorization": f"Bearer {access_token}"
}
) print(response.json())

The result tells you exactly which account Graph sees.

For testing, I authenticated with two different accounts.

The result behaved exactly as expected:

Login as User A
-> /me returns User A Login as User B
-> /me returns User B

That sounds obvious, but it is an important validation when testing delegated access.


Searching SharePoint

Microsoft Graph exposes a search endpoint that can search SharePoint and OneDrive content:

POST https://graph.microsoft.com/v1.0/search/query

For document searches, the interesting entity type is:

driveItem

A minimal request looks like this:

headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
} body = {
"requests": [
{
"entityTypes": ["driveItem"],
"query": {
"queryString": "contract"
},
"from": 0,
"size": 50,
}
]
} response = requests.post(
"https://graph.microsoft.com/v1.0/search/query",
headers=headers,
json=body,
) print(response.json())

And this is where things become useful.

The query is not limited to filenames.

Depending on indexing, SharePoint Search can also return matches based on metadata and document content.

So a search such as:

contract
agreement
invoice
tax
supplier name
customer name
year

can surface documents buried several folders deep without manually navigating the structure.


The Interesting Security Test

Authentication working does not prove that authorization is correct.

So I created a more useful test.

Instead of only searching for documents, I authenticated using a dedicated test account and inspected which SharePoint content Graph could actually return.

The search results included documents from several different departmental areas.

At first glance that might look alarming.

But it actually revealed something useful:

The test account itself had access to those locations.

The delegated Graph application was simply reflecting the existing SharePoint permission model.

That is exactly what it was supposed to do.

This is also an important reminder when troubleshooting SharePoint:

Sometimes an API does not reveal an authorization problem.

It reveals a SharePoint permission problem that already existed.


Listing Visible Folders

Search results are useful, but when validating permissions I prefer something simpler.

List the folders the user can actually browse.

For a document library:

GET /drives/{drive-id}/root/children

For example:

response = requests.get(
f"https://graph.microsoft.com/v1.0/drives/{drive_id}/root/children",
headers={
"Authorization": f"Bearer {access_token}"
}
) for item in response.json().get("value", []):
if "folder" in item:
print(item["name"])

That produces a much clearer picture of the visible SharePoint structure.

Something like:

Accounting
Finance
Legal
Marketing
HR
Operations

If a test user sees more departments than expected, the next question should not be:

Why is Graph giving the app too much access?

The better question is:

Why does this user already have access to those SharePoint locations?

That distinction saved quite a bit of debugging time.


Adding a Second Boundary

Delegated permissions solve the authorization problem, but I still prefer applying defense in depth.

Imagine the user legitimately has access to:

Finance
Marketing
Legal
Product

but the tool is intended exclusively for Finance research.

Technically, delegated Graph access could search all four.

So the application should apply its own search boundary as well.

For example:

User must have access
AND
document must be inside the configured Finance scope

SharePoint Search supports path restrictions, which makes this easy.

Conceptually:

contract path:"<configured-sharepoint-location>"

This gives two independent controls:

SharePoint permissions
+
Application search scope

Neither replaces the other.


Why I Prefer This Model

For interactive internal tooling, delegated Graph permissions have several advantages.

Existing permissions remain authoritative

There is no second permission universe to maintain.

If access is removed in SharePoint, the API access disappears as well.

No service account

No shared account with a password that eventually ends up in a password manager nobody owns.

No application secret

For the desktop prototype, authentication uses the user's Microsoft identity directly.

MFA still applies

The user goes through the existing Entra authentication process.

Conditional Access still applies

The application does not bypass existing identity controls.

Auditing makes more sense

Actions happen in a user context rather than behind one giant technical identity.


What I Would Not Do

For this particular use case, I would avoid building the tool around:

client_credentials

combined with:

Sites.FullControl.All

or:

Files.Read.All (Application)

Those permissions have valid uses.

This simply was not one of them.

Application permissions make much more sense for unattended processes such as:

Scheduled jobs
ETL processes
Backup tooling
Document processors
Integration services
CI/CD pipelines

For an employee sitting in front of a search tool, delegated permissions are usually a much more natural security boundary.


One More Lesson: API Access Does Not Fix SharePoint Architecture

The Graph API solved the immediate search problem.

It did not solve the underlying information architecture.

If historical documents are scattered across:

Department folders
Old project folders
Personal conventions
Nested subfolders
Multiple document libraries
Inconsistent metadata

then a better search interface is useful, but it is still treating a symptom.

The longer-term work remains:

Information architecture
Permissions
Retention
Metadata
Naming conventions
Ownership
Lifecycle management

The API simply gives us a much better flashlight while exploring the basement.


Final Architecture

The final prototype is pleasantly boring:

Python
|
v
MSAL
|
v
Interactive Microsoft Login
|
v
Delegated Microsoft Graph Token
|
v
Microsoft Graph Search
|
v
SharePoint
|
+-- Existing user permissions
|
+-- Configured search scope

No credentials in source code.

No privileged service account.

No global SharePoint application access.

Just the user's existing permissions, exposed through a much more useful interface.

Sometimes the best automation is not about gaining more access.

It is about making the access you already have usable.


Tags: Microsoft 365 SharePoint Microsoft Graph Entra ID Python MSAL Automation Security


β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2
ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9
SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!

Scribble: Get-WindowsAutopilotInfo, the Graph API, and the "Need Admin Approval" Trap

Welcome back to the Terminal Garden! Today, we're taking a look at a true classic from my daily IT lifeβ€”a typical case of Building, Breaking, and Automating.

The scenario is simple: A new notebook is unboxed, you are sitting in the Windows OOBE (Out-of-Box Experience), you open the console, and you just want to quickly upload the hardware hash to Microsoft Intune via script. It's supposed to be an absolute standard procedureβ€”until Microsoft Entra ID suddenly decides to batten down the hatches.

πŸ’₯ The Problem: AADSTS90094 during Autopilot Upload

A colleague, officially assigned the Intune Administrator role in the tenant, prepares the device and runs the well-known command:

PowerShell

Get-WindowsAutopilotInfo.ps1 -Online

The interactive Microsoft sign-in window pops up, credentials are enteredβ€”but instead of a successful upload in green text, this roadblock appears:

Need admin approval
Microsoft Graph Command Line Tools needs permission to access resources in your organisation that only an admin can grant.
Error Code: 90094

πŸ•΅οΈβ€β™‚οΈ The Illusion of the Global Admin

The crazy part: If you test the exact same command as a Global Administrator on your own machine, everything runs flawlessly. Why?

Under the hood, the script uses the first-party app Microsoft Graph Command Line Tools. As a Global Admin, you've often unknowingly granted the permissions (scopes) requested by the script for your own account in the past (User Consent), or you simply have the inherent rights to accept them on the fly.

The regular Intune Admin, however, hits an invisible wall. Entra ID is merciless here: If even a single permission requested by the script is missing from the global organizational approval (Tenant-wide Admin Consent), the entire login attempt is blocked.

πŸ› οΈ The Fix: Stop Guessing, Start Reading!

Instead of blindly clicking together permissions in the Azure Portal, playing app registration roulette, or digging through cryptic JSON logs, we can use PowerShell to simply ask the script itself what it actually wants.

Since the Autopilot script sits open-source on the local drive, we can pull the required scopes directly from the code. Just run this command locally:

PowerShell

Get-Content (Get-Command Get-WindowsAutopilotInfo.ps1).Path | Select-String "Scopes"

Depending on the script version, the output will look something like this:

Plaintext

Connect-MgGraph -Scopes "DeviceManagementServiceConfig.ReadWrite.All", "DeviceManagementManagedDevices.ReadWrite.All", "Device.ReadWrite.All", "Group.ReadWrite.All", "GroupMember.ReadWrite.All"

There is our culprit in black and white! In most cases, it's exactly the group permissions (Group.ReadWrite.All, GroupMember.ReadWrite.All) that are missing from the global consent for this app.

Rolling Out the Permissions Tenant-Wide

To finally cut the Gordian knot for all authorized employees in the tenant, we (as a Global Admin) need to grant these scopes globally once:

1. Disconnect the existing session:

To avoid caching issues, we first toss out any old tokens.

PowerShell

Disconnect-MgGraph

2. Reconnect with the exact scopes:

We now use the exact list of rights the script spit out earlier.

PowerShell

Connect-MgGraph -Scopes "DeviceManagementServiceConfig.ReadWrite.All", "DeviceManagementManagedDevices.ReadWrite.All", "Device.ReadWrite.All", "Group.ReadWrite.All", "GroupMember.ReadWrite.All"

3. Grant global consent:

The sign-in window opens. Here, we log in with our Global Admin account. In the following consent prompt comes the most crucial step: Check the box for "Consent on behalf of your organization" and click Accept.

⚠️ The Most Important Final Step: Clear the Cache!

If your colleague now enthusiastically runs the command again in the still-open OOBE window, they will very likely run into an error again.

The reason is simple: The Windows Web Account Manager (WAM) and the Graph session still have the old, blocked token hanging in the background cache.

The solution: Hard reboot the notebook in OOBE via the power button or console command (shutdown /r /t 0). The device will simply boot back to the language selection screen. After that, freshly open the console via Shift + F10, start the scriptβ€”and the upload will go through smoothly.

Happy Automating!


β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2
ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9
SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!

πŸ—‚οΈ Microsoft Forms – Admin Guide: Form Ownership Transfer

Source: Microsoft Learn – Admin information for Microsoft Forms


πŸ“‹ Overview

When an employee leaves your organization, their Microsoft Forms don't automatically transfer to someone else. This article explains how administrators can take ownership of those forms using a dedicated delegation URL β€” and what requirements and limitations apply.


πŸ” Who Can Transfer Form Ownership?

Only the following roles can perform a form ownership transfer:

  • βœ… Global Administrator
  • βœ… Office Application Administrator (with a valid Microsoft Forms license)

⚠️ Regular users and standard admins cannot perform this action.


βœ… Requirements Before You Transfer

All of the following conditions must be met before a transfer is possible:

#

Requirement

1️⃣

You are the Office Application Administrator with a valid Forms license

2️⃣

The employee's account has been deleted or disabled in Microsoft Entra ID (Azure AD)

3️⃣

(If account was deleted) The transfer happens within 30 days of account deletion

πŸ’‘ Note: There is no time restriction for transferring forms from a disabled (but not deleted) account.


πŸ”— The Delegation URL

Microsoft provides a special URL to access the forms of a departed employee:

https://forms.office.com/Pages/delegatepage.aspx?originalowner=[email address]

πŸ”„ How to Use It

  1. Open your browser's address bar
  2. Replace [email address] with the email address of the former form owner
  3. Example:
    https://forms.office.com/Pages/delegatepage.aspx?originalowner=JasonFabian@contoso.com
  4. You will now see all forms belonging to that user
  5. On the form you want to transfer, click More form actions (β‹―) β†’ Move

πŸ’‘ Tip: If the email address doesn't return results, try substituting the user's Object ID in place of the email address in the URL.


πŸ”Ž How to Check If an Account Was "Hard Deleted"

Before attempting a transfer, verify the account status via Microsoft Graph:

  1. Go to Microsoft Graph Explorer
  2. Run the following query (replace *user email* with the actual address):
    https://graph.microsoft.com/v1.0/directory/deletedItems/microsoft.graph.user?$filter=mail eq '*user email*'

Query Result

Meaning

Transfer Possible?

βœ… Account info returned

Soft deleted, within 30-day window

βœ… Yes

❌ No result

Either still active, or deleted > 30 days ago

⚠️ Depends

❌ No result + >30 days since deletion

Hard deleted

❌ No β€” forms are unrecoverable


❌ Common Error Messages & Solutions

Error Message

Cause

Solution

"We can't access this page – The form's owner still has an active account."

Owner has an active Forms license and account

Wait until the account is disabled or deleted

"We can't access this page – Make sure you've entered the email address correctly and the forms owner account wasn't deleted more than 30 days ago."

Wrong email or account deleted > 30 days ago

Verify the email; check deletion date

"We can't access this page – Make sure you've entered the email address correctly, and then try again."

Email is missing or misspelled

Double-check the URL


🏒 Transferring to a Group (Active Employees)

If you want to transfer ownership to a currently active employee, you can move the form to a group they belong to.

⚠️ Important: You must be a member of that group to perform the transfer. You may join the group, complete the transfer, and then leave the group afterward.


⚠️ Known Limitations (Microsoft Defender for Cloud Apps)

If your organization uses Microsoft Defender for Cloud Apps, the following scenarios may not work correctly:

  • πŸ”΄ Template / form duplication β€” user may get stuck on a loading screen
  • πŸ”΄ Form ownership transfer (user β†’ group) on the delegate page β€” user may be blocked
  • πŸ”΄ Admin phishing form unblock on the Admin review page β€” user may be blocked

πŸ“Œ Quick Reference Summary

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ TRANSFER CHECKLIST β”‚
β”‚ β”‚
β”‚ β–‘ You are a Global Admin or Office App Admin β”‚
β”‚ β–‘ Former employee's account is disabled or deleted β”‚
β”‚ β–‘ If deleted: transfer within 30 days β”‚
β”‚ β–‘ Use delegation URL with correct email / Object ID β”‚
β”‚ β–‘ Move the form via "More form actions" β†’ Move β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ”— Related Links


πŸ“… Last reviewed: May 2026 | Based on Microsoft documentation last updated: 2024-05-31


β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2
ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9
SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!

Bulk User Assignment to Exchange Online Groups via PowerShell

When a batch of new team members needs access to multiple Microsoft 365 or Distribution Groups, the admin portal becomes the wrong tool immediately. Here is how to automate it cleanly β€” with type detection, error handling, and a CSV audit trail.


The Scenario

You have a list of users and a list of group Object IDs. Every user needs to be a member of every group. Doing this manually through the Microsoft 365 Admin Center means navigating to each group, opening the members panel, searching for each user, and confirming β€” multiplied by the number of users times the number of groups.

For 15 users and 3 groups, that is 45 manual operations. Automate it instead.


The Challenge: Two Group Types, Two Cmdlets

Exchange Online uses completely different cmdlets depending on the group type:

Group Type

Add Member Cmdlet

Microsoft 365 Group

Add-UnifiedGroupLinks

Distribution Group / Mail-Enabled Security Group

Add-DistributionGroupMember

Passing a GUID to the wrong cmdlet fails silently or throws an unhelpful error. The script handles this automatically by probing the group type first and branching accordingly.

Use GUIDs as identifiers, not display names. Display names are user-editable and can cause silent mismatches. Object IDs are immutable.


The Script

==PowerShell==

# ============================================================
# Add-UsersToGroups.ps1
# Adds a list of users to Exchange Online groups.
# Supports Microsoft 365 Groups and Distribution Groups.
# ============================================================ # Connect to Exchange Online
Write-Host "Connecting to Exchange Online..." -ForegroundColor Cyan
Connect-ExchangeOnline # ── Users ────────────────────────────────────────────────────
$Users = @(
"user1@yourdomain.com"
"user2@yourdomain.com"
"user3@yourdomain.com"
# Add remaining users here
) # ── Groups (Object IDs) ──────────────────────────────────────
$Groups = @(
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
"yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
"zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz"
) # ── Processing ───────────────────────────────────────────────
$Results = @() foreach ($GroupId in $Groups) {
Write-Host "`nProcessing group: $GroupId" -ForegroundColor Yellow # Detect group type
$Group = $null
try {
$Group = Get-UnifiedGroup -Identity $GroupId -ErrorAction Stop
$GroupType = "UnifiedGroup"
Write-Host " Type: Microsoft 365 Group | Name: $($Group.DisplayName)" -ForegroundColor Green
}
catch {
try {
$Group = Get-DistributionGroup -Identity $GroupId -ErrorAction Stop
$GroupType = "DistributionGroup"
Write-Host " Type: Distribution Group | Name: $($Group.DisplayName)" -ForegroundColor Green
}
catch {
Write-Warning " Group '$GroupId' not found β€” skipping."
$Results += [PSCustomObject]@{
Group = $GroupId
User = "N/A"
Status = "ERROR – Group not found"
}
continue
}
} foreach ($User in $Users) {
try {
if ($GroupType -eq "UnifiedGroup") {
Add-UnifiedGroupLinks -Identity $GroupId -LinkType Members -Links $User -ErrorAction Stop
}
else {
Add-DistributionGroupMember -Identity $GroupId -Member $User -ErrorAction Stop
} Write-Host " [OK] $User added" -ForegroundColor Green
$Results += [PSCustomObject]@{
Group = $Group.DisplayName
User = $User
Status = "Successfully added"
}
}
catch {
$ErrMsg = $_.Exception.Message if ($ErrMsg -match "already a member") {
Write-Host " [~] $User is already a member" -ForegroundColor DarkYellow
$Results += [PSCustomObject]@{
Group = $Group.DisplayName
User = $User
Status = "Already a member"
}
}
else {
Write-Warning " [ERROR] $User – $ErrMsg"
$Results += [PSCustomObject]@{
Group = $Group.DisplayName
User = $User
Status = "ERROR: $ErrMsg"
}
}
}
}
} # ── Summary ───────────────────────────────────────────────────
Write-Host "`n========== SUMMARY ==========" -ForegroundColor Cyan
$Results | Format-Table -AutoSize # Export results as CSV
$CsvPath = "$PSScriptRoot\Add-UsersToGroups_Results.csv"
$Results | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Host "Results saved to: $CsvPath" -ForegroundColor Cyan Disconnect-ExchangeOnline -Confirm:$false
Write-Host "Disconnected." -ForegroundColor Cyan

How It Works

1. Group type detection The script tries Get-UnifiedGroup first. If that throws, it falls back to Get-DistributionGroup. The detected type is stored and used to select the correct add-member cmdlet for every user in that group.

2. Already a member handling Without this, every pre-existing membership throws a red error and pollutes the output. The script matches the exception message and re-classifies it as a non-fatal warning.

3. CSV audit trail After every run, a results file is written alongside the script. Three possible status values:

Status

Meaning

Successfully added

User was not a member and has been added

Already a member

User was already in the group β€” no change made

ERROR: ...

Something went wrong β€” full message appended


Prerequisites and Usage

Install the Exchange Online module if not already present:

==PowerShell==

Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser

Run the script:

==PowerShell==

.\Add-UsersToGroups.ps1

A browser authentication prompt will appear for Exchange Online. After sign-in, the script runs unattended and disconnects automatically.


15 users. 3 groups. 45 clicks saved. One script.


β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2
ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9
SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!

When "Getting Windows Ready" Becomes a Nightmare – and TrustedInstaller Is the Villain

Tags: hyper-v windows-server troubleshooting trustedinstaller windows-update


It started like a completely normal evening. A Windows Server 2016 VM sitting on Hyper-V, spinning away at the "Getting Windows Ready" screen – for almost two hours. What followed was a deep dive through checkpoints, corrupted VHDX chains, registry hives, and finally one single command that solved everything.

Here's the full story.


The Setup

  • Host: Hyper-V on Windows Server
  • Guest: Windows Server 2016 VM
  • Symptom: Stuck on "Getting Windows Ready" after a cumulative Windows Update – for nearly 2 hours

Phase 1 – The Obvious Steps

First instinct: wait it out. HDD activity can spike during large updates, especially on VMs with spinning disks or limited I/O. But after two hours with no progress, it was time to act.

A hard reset was attempted. Then another. Both times, Windows booted straight back into the same screen – a classic update loop.

The loop happens when Windows Update partially applies a patch, sets pending operations in the registry or filesystem, and then fails silently on every reboot. Windows keeps trying. Windows keeps failing. You keep staring at the same screen.


Phase 2 – Mounting the VHDX Directly

Since the VM was unreachable via RDP and WinRE wasn't triggering automatically, the plan was to mount the VHDX directly from the Hyper-V host and clean up the update cache manually.

Mount-VHD -Path "D:\Hyper-V\VMNAME\Virtual Hard Disks\vmname-disk-c.vhdx" -ReadWrite

The target: rename the SoftwareDistribution folder to break the update loop.

ren E:\Windows\SoftwareDistribution SoftwareDistribution.old

βœ… Done. VHDX dismounted. VM started.

Result: Still stuck on "Getting Windows Ready."


Phase 3 – The VHDX Chain Breaks

Here's where things got interesting – and slightly nerve-wracking.

It turned out the VM had an existing checkpoint (.avhdx differencing disk). By mounting the parent VHDX directly, the parent-child relationship between the base disk and the differencing disk was broken. Hyper-V tracks this relationship using internal identifiers, and mounting the parent externally caused an ID mismatch.

The error on next start:

The chain of virtual hard disks is corrupted. 
There is a mismatch in the identifiers of the parent virtual hard disk
and differencing disk.

Not great. But fixable.

The Fix: Set-VHD -IgnoreIdMismatch

Set-VHD `
-Path "D:\Hyper-V\VMNAME\Virtual Hard Disks\vmname-disk-c_<GUID>.avhdx" `
-ParentPath "D:\Hyper-V\VMNAME\Virtual Hard Disks\vmname-disk-c.vhdx" `
-IgnoreIdMismatch

This command rewrites only the metadata reference in the differencing disk – no data is moved, nothing is overwritten. It's the surgical fix for exactly this scenario.

⚠️ Lesson learned: Never mount a parent VHDX directly when the VM has active checkpoints. Always merge or delete checkpoints first – or use Mount-VHD on the differencing disk itself.

VM started again. Still "Getting Windows Ready." Back to square one, but at least the chain was intact.


Phase 4 – WinRE and the Registry Hunt

With SoftwareDistribution already renamed and the VHDX chain repaired, the next target was the registry. The goal: find and remove any pending operation flags that were keeping Windows in the loop.

WinRE was triggered using the 3x hard reset method – forcefully shutting down the VM three times during the boot sequence until Windows automatically drops into the recovery environment.

In the WinRE command prompt, the SYSTEM and SOFTWARE hives were loaded manually:

reg load HKLM\TEMPSOFT C:\Windows\System32\config\SOFTWARE

reg query "HKLM\TEMPSOFT\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"
reg query "HKLM\TEMPSOFT\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired" reg unload HKLM\TEMPSOFT

Note: In WinRE, the Windows partition is often not C:\ – always verify with dir C:\Windows, dir D:\Windows, etc. first.

Neither key existed. No PendingFileRenameOperations either. The registry was clean.

The update loop had no obvious flag driving it. Something else was keeping Windows stuck.


Phase 5 – The Real Culprit: TrustedInstaller

Here's the twist nobody expects.

The VM was still reachable on the network during the "Getting Windows Ready" phase. A quick check revealed that TrustedInstaller.exe – the Windows Modules Installer, responsible for applying updates – was still running as a process and had simply hung. It wasn't crashing, it wasn't erroring out. It was just... stuck. And Windows was politely waiting for it to finish before proceeding with the boot.

The fix was one command, executed remotely from another machine:

taskkill /S 10.x.x.x /U Administrator /P ******** /IM trustedinstaller.exe

Windows immediately continued booting. Server came up clean.


Root Cause Summary

Stage

Finding

Initial symptom

TrustedInstaller.exe hung during update application

Why looping

Process still alive across reboots, Windows waited for it

SoftwareDistribution rename

Didn't help – process was the issue, not the cache

VHDX mount

Caused ID mismatch due to existing checkpoint

Registry check

No pending flags found

Actual fix

Remote taskkill of trustedinstaller.exe


Key Takeaways

1. Check network connectivity first If the VM is still reachable during "Getting Windows Ready", you have more options than you think. taskkill /S is a powerful remote tool.

2. Never mount a parent VHDX with active checkpoints Use Set-VHD -IgnoreIdMismatch to recover, but avoid it in the first place.

3. TrustedInstaller can hang silently No event log entry, no crash dump, no obvious sign – it just sits there. If you're stuck in a "Getting Windows Ready" loop with no registry flags and a clean SoftwareDistribution, check whether TrustedInstaller.exe is still alive.

4. Always back up before touching registry or VHDX

Copy-Item "vmname-disk-c.vhdx" "vmname-disk-c.vhdx.bak"
reg export "HKLM\TEMP\ControlSet001\Control\Session Manager" C:\backup.reg

Two hours of troubleshooting. One taskkill. Sometimes IT is exactly like that.


β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2
ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9
SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!

Recursively Get All Members of an Exchange Online Distribution List (Including Dynamic & M365 Groups)

Getting a complete, flat list of end-users from a massive, nested distribution list in Exchange Online can be surprisingly frustrating.

If you try to use the standard Get-DistributionGroupMember cmdlet, you will quickly notice two things:

  1. It lacks a native -Recursive parameter.
  2. If your main distribution list contains nested Microsoft 365 Groups (Unified Groups) or Dynamic Distribution Groups, a simple recursive loop will crash and flood your console with red ManagementObjectNotFoundException errors.

Different group types in Exchange Online require completely different cmdlets. Here is a robust PowerShell function that dynamically identifies the group type, uses the correct cmdlet to fetch its members, and gracefully skips broken or orphaned objects without crashing.

The PowerShell Script

Copy and paste this function into your Exchange Online PowerShell session:

PowerShell

Function Get-DistributionGroupMemberRecursive {
param (
[Parameter(Mandatory=$true)]
[string]$Identity,
[string]$GroupType = "DistributionGroup" # Default starting type
) $Members = $null try {
# 1. Select the correct cmdlet based on the group type
if ($GroupType -match "GroupMailbox|UnifiedGroup") {
# For Microsoft 365 Groups
$Members = Get-UnifiedGroupLinks -Identity $Identity -LinkType Members -ErrorAction Stop
}
elseif ($GroupType -match "DynamicDistributionGroup") {
# For Dynamic Distribution Groups
$Members = Get-DynamicDistributionGroupMember -Identity $Identity -ErrorAction Stop
}
else {
# For Classic Distribution Lists / Security Groups
$Members = Get-DistributionGroupMember -Identity $Identity -ResultSize Unlimited -ErrorAction Stop
}
}
catch {
# Gracefully handle orphaned objects or resolution errors
Write-Warning "Failed to read group '$Identity' ($GroupType). It may be an orphaned object."
return
} # 2. Iterate through members and evaluate
if ($null -ne $Members) {
foreach ($Member in $Members) {

$MemberType = $Member.RecipientTypeDetails # Check if the member is a nested group and recurse accordingly
if ($MemberType -match "GroupMailbox|UnifiedGroup") {
Get-DistributionGroupMemberRecursive -Identity $Member.PrimarySmtpAddress -GroupType "GroupMailbox"
}
elseif ($MemberType -match "DynamicDistributionGroup") {
Get-DistributionGroupMemberRecursive -Identity $Member.PrimarySmtpAddress -GroupType "DynamicDistributionGroup"
}
elseif ($MemberType -match "Group") {
Get-DistributionGroupMemberRecursive -Identity $Member.PrimarySmtpAddress -GroupType "DistributionGroup"
}
else {
# Output the actual end-user (UserMailbox, SharedMailbox, MailContact, etc.)
$Member | Select-Object Name, PrimarySmtpAddress, RecipientTypeDetails
}
}
}
}

Why This Approach Works

  • Smart Cmdlet Switching: The script checks the RecipientTypeDetails of every nested group. It seamlessly switches to Get-UnifiedGroupLinks for M365 Groups and Get-DynamicDistributionGroupMember when it needs to calculate dynamic rule-based memberships.
  • Error Handling: Real-world Active Directories are messy. The try/catch block ensures that if a nested group contains a deleted user or an orphaned SID, the script throws a gentle yellow warning instead of a fatal error, allowing the rest of the extraction to continue.

How to Use and Export

Once the function is loaded into your session, you can run it against your parent group. To make the output useful, you can pipe the results directly into a CSV file.

Note: Because users might be members of multiple nested groups, adding Select-Object -Unique is highly recommended to filter out duplicates.

PowerShell

# Run the script and export a clean, deduplicated list to a CSV file
Get-DistributionGroupMemberRecursive -Identity "company.news@yourdomain.com" |
Select-Object Name, PrimarySmtpAddress, RecipientTypeDetails -Unique |
Export-Csv -Path "C:\temp\CompanyNews_Members.csv" -NoTypeInformation -Encoding UTF8

β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2
ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9
SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!

πŸ› οΈ Exchange Online: The "Disabled" Flag Bug After Mailbox Conversion

Have you recently converted a Shared Mailbox to a Regular User Mailbox, assigned a license, and yet OWA keeps crashing on login?

Error: AccountTerminationException | st: 440

Symptom: SyntaxError: JSON.parse: unexpected end of data

Even though the Microsoft 365 Admin Center shows everything as "Healthy," the mailbox is stuck in a ghost state. Here is how to fix the Disabled Flag Bug using PowerShell.


The Core Problem: Why Login Fails

By design, Shared Mailboxes are user accounts where direct login is disabled. When you convert a mailbox to Regular, Exchange updates the mailbox type but often "forgets" to flip the sign-in flag in the underlying Microsoft Entra (Azure AD) identity.

The result: The mailbox exists and the license is active, but the server kills the authentication mid-stream because it still thinks the user isn't allowed to log in.


The Solution: The PowerShell Fix

To resolve this, we must manually force the AccountDisabled attribute to False.

1. Verify the Status

Connect to Exchange Online and check what the system actually thinks of the account:

PowerShell

Get-User -Identity "info@yourdomain.com" | Select-Object Name, RecipientTypeDetails, AccountDisabled

If AccountDisabled returns True, you’ve found your culprit.

2. Force the Login to Enable

Run the following command to lift the restriction:

PowerShell

Set-Mailbox -Identity "info@yourdomain.com" -AccountDisabled $false

3. Sync the Identity via Microsoft Graph

If OWA still throws the 440 error after the command above, the user object itself must be enabled in the Microsoft 365 directory:

PowerShell

# Requires the Microsoft.Graph module
Update-MgUser -UserId "info@yourdomain.com" -AccountEnabled $true

Troubleshooting Checklist

If your conversion is still stuck, work through this list:

  1. Licensing: Is an Exchange Online license (Plan 1/2 or Business) actually assigned?
  2. Password: Was a new password set after the conversion?
  3. AccountEnabled: Is the flag set to $true (or AccountDisabled $false) via PowerShell?
  4. Browser Cache: Test in Incognito Mode. This is critical. OWA aggressively caches "Shared Mailbox" session tokens, which will trigger the JSON error even if the backend is fixed.

Written for admins who don't have time to wait 24 hours for "Replication."


β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2
ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9
SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!

πŸš€ Scripting Bulk Mailbox Delegation in Exchange Online

When managing a growing team, you often find yourself in a situation where a lead user (e.g., a department head or administrator) needs access to multiple shared or functional mailboxes.

Manually clicking through the Microsoft 365 Admin Center for 10+ mailboxes is a recipe for boredom and typos. Here is how I solved this using the Exchange Online PowerShell V3 module.

The Scenario

We need to grant one primary user (User A) two specific types of permissions across a list of target mailboxes:

  1. FullAccess: The ability to open the mailbox, read, and organize emails.
  2. SendAs: The ability to send emails appearing as the target mailbox address.

The PowerShell Solution

First, ensure you are connected:

Connect-ExchangeOnline

Then, run this script to loop through your targets:

PowerShell

# 1. Define the Lead User (The one receiving the permissions)
$LeadUser = "primary.user@company-it.com" # 2. Define the list of target mailboxes (Shared or User mailboxes)
$TargetMailboxes = @(
"info@company-it.com",
"accounting@company-it.com",
"logistics@company-it.com",
"project-alpha@company-it.com",
"support@company-it.com",
"hr-dept@company-it.com"
) # 3. Apply permissions via loop
foreach ($Mailbox in $TargetMailboxes) {
Write-Host "Processing: $Mailbox" -ForegroundColor Cyan

# Grant Full Access (includes Auto-Mapping for Outlook Desktop)
Add-MailboxPermission -Identity $Mailbox -User $LeadUser -AccessRights FullAccess -InheritanceType All -Confirm:$false

# Grant Send As permissions
Add-RecipientPermission -Identity $Mailbox -Trustee $LeadUser -AccessRights SendAs -Confirm:$false
} Write-Host "Success: All permissions applied." -ForegroundColor Green

β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2
ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9
SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!


β˜• Support the Terminal Garden

If this scribble saved you a few hours of reading Microsoft documentation, consider fueling the next late-night PowerShell session.

BTC: bc1qp06uajaxpu2dxzqhqsvcxesp7uydgy5829nag2
ETH: 0x54644d3af213fed1cc6e2c96d2dcd2189014b6f9
SOL: Gwu9BKKYe97ukrVUYDQ3Mp96Ru3hu9HK82oe4Lg4haBp

Building, breaking, and automating runs on coffee and community. Thank you!