Let’s be honest: almost every M365 environment has that one legacy service account. You know the one—created three years ago as a standard user object (svc_backup or m365_reporter), password set to never expire, excluded from Conditional Access because MFA would break the scheduled script, and hardcoded into an old XML file on a local VM.
When Microsoft pushes harder toward modern authentication and Zero Trust, these legacy accounts become ticking time bombs. Between legacy protocol deprecations and security baselines screaming about non-expiring passwords, keeping user-based service accounts alive is a risk we shouldn’t be taking anymore.
The right move is shifting them to Microsoft Entra Workload Identities (App Registrations paired with certificates or secrets). Here is how I audit them, replace them, and clean up the environment without breaking production workflows.
Why Standard User Accounts Make Terrible Service Accounts
Before jumping into scripts, let’s look at why relying on regular user accounts for automation hurts us operationally:
- The MFA Exemption Dilemma: To keep a script running, you usually have to carve out risky exceptions in your Conditional Access policies.
- Plaintext Exposure: Static passwords living inside local PowerShell scripts or Task Scheduler definitions are easily accessible if someone compromises that management VM.
- The Deprecation Clock: Microsoft is steadily pulling the plug on legacy authentication endpoints. Password-only script execution is on borrowed time.
Moving to Workload Identities lets us ditch passwords entirely and scope permissions strictly to what the script actually needs via Microsoft Graph.
Step 1: Run the Audit Script to Find Your Legacy Accounts
Before changing anything, you need to know the scale of the problem. I use a quick PowerShell 7 script to scan the tenant for user accounts that look like service accounts (checking for non-expiring passwords or specific naming conventions).
<#
.SYNOPSIS
Quick audit script to find potential user-based service accounts in Entra ID.
.NOTES
Author: DS Innovation Guru
Site: https://dsinnovationguru.com
#>
# Make sure you've got the Microsoft.Graph module installed
Connect-MgGraph -Scopes "User.Read.All", "Directory.Read.All"
Write-Host "Scanning tenant user objects..." -ForegroundColor Cyan
$serviceAccounts = Get-MgUser -All -Property Id, DisplayName, UserPrincipalName, AccountEnabled, PasswordPolicies, CreatedDateTime |
Where-Object {
$_.PasswordPolicies -match "DisablePasswordExpiration" -or
$_.UserPrincipalName -like "svc_*" -or
$_.UserPrincipalName -like "*service*"
}
$report = foreach ($account in $serviceAccounts) {
[PSCustomObject]@{
DisplayName = $account.DisplayName
UserPrincipalName = $account.UserPrincipalName
AccountEnabled = $account.AccountEnabled
PasswordNeverExpires = ($account.PasswordPolicies -contains "DisablePasswordExpiration")
CreatedDate = $account.CreatedDateTime
}
}
$outputPath = ".\EntraID_LegacyServiceAccounts_Audit.csv"
$report | Export-Csv -Path $outputPath -NoTypeInformation
Write-Host "Done! Audit sheet dropped at $outputPath" -ForegroundColor Green
Step 2: Register a Workload Identity (App Registration)
Let’s say you found an old account running a daily user licensing report. Instead of using that user login, we’ll build a dedicated App Registration.
You can spin this up quickly via PowerShell rather than clicking through the portal:
# Create the App Registration for our automation task
$app = New-MgApplication -DisplayName "App-M365-Licensing-Report"
$sp = New-MgServicePrincipal -AppId $app.AppId
Write-Host "App successfully registered!" -ForegroundColor Green
Write-Host "Client ID: $($app.AppId)" -ForegroundColor Yellow
Step 3: Lock Down Permissions & Attach a Certificate
Rule number one: Never give an app Global Admin. Scope it strictly.
- Head over to the Entra Admin Center → App registrations → find your new app.
- Go to API permissions → Add a permission → Microsoft Graph → Application permissions.
- Check only what’s required (e.g.,
User.Read.All) and hit Grant admin consent.
Next, skip client secrets. For production scripts, use a self-signed certificate:
# Generate a clean 2-year cert locally
$cert = New-SelfSignedCertificate -Subject "CN=App-M365-Licensing-Report" `
-CertStoreLocation "Cert:\CurrentUser\My" `
-KeyExportPolicy Exportable `
-KeySpec Signature `
-KeyLength 2048 `
-KeyAlgorithm RSA `
-HashAlgorithm SHA256 `
-NotAfter (Get-Date).AddYears(2)
# Export the public key (.cer) to upload to Entra
Export-Certificate -Cert $cert -FilePath ".\AppLicensingReport.cer"
Write-Host "Certificate ready! Upload the .cer file to your App Registration." -ForegroundColor Green
Upload that .cer file under Certificates & secrets in the Azure portal.
Step 4: Rewrite the Script Authentication
Now update your scheduled task or background script to authenticate using the certificate thumbprint instead of interactive credentials or stored passwords:
$TenantId = "YOUR_TENANT_ID"
$AppId = "YOUR_CLIENT_ID"
$CertThumbprint = "YOUR_CERT_THUMBPRINT"
# Connect silently via certificate
Connect-MgGraph -TenantId $TenantId -AppId $AppId -CertificateThumbprint $CertThumbprint
Write-Host "Authenticated cleanly via Workload Identity certificate." -ForegroundColor Green
# Run your core automation logic safely
Get-MgUser -Top 5 | Select-Object DisplayName, UserPrincipalName
Step 5: The Safe Decommissioning Process
Don’t just delete the old user account right away—something will inevitably break if you missed a hidden script.
- Disable it first: Set
AccountEnabled = $falseon the old user object. - Watch the logs: Give it a week or two. If nobody complains and your logs are clean, you’re safe.
- Kill active sessions: Revoke tokens just to be clean:
Revoke-MgUserSignInSession -UserId "svc_licensing@yourdomain.com"
Tidying this up takes a little upfront effort, but replacing fragile user service accounts with proper Workload Identities lets you sleep a lot better when audit time rolls around.
