Microsoft has confirmed that the -Credential parameter in Connect-ExchangeOnline and Connect-IppsSession (Security & Compliance PowerShell) will be permanently retired in December 2026 alongside updates in ExchangeOnlineManagement v3.8+.
Why is -Credential being removed? When you pass a PSCredential object ($cred = Get-Credential or an encrypted XML credential file) to Connect-ExchangeOnline -Credential $cred, the module relies on the legacy OAuth 2.0 Resource Owner Password Credentials (ROPC) grant flow. ROPC transmits raw username/password credentials directly to the token endpoint, bypasses Multi-Factor Authentication (MFA), fails under modern Conditional Access policies, and has been removed from newer versions of the Microsoft Authentication Library (MSAL).
If your organization still runs unattended Windows Scheduled Tasks, Azure Automation runbooks, or CI/CD scripts using Connect-ExchangeOnline -Credential, those scripts will fail next month. In this guide—live-tested against our LAB 365 (lab365.in) tenant—we walk through the complete migration to Certificate-Based App-Only Authentication (Exchange.ManageAsApp).
Architecture: Legacy ROPC (-Credential) vs. Modern Certificate App-Only Auth
Connect-ExchangeOnline -Credential $Cred- Requires MFA exclusion on service account
- Blocked by MSAL & Security Defaults
- Private key locked in
Cert:\CurrentUser\My - Public
.ceruploaded to Entra App - Signs RFC 7523 JWT
client_assertion
Connect-ExchangeOnline -AppId -CertificateThumbprint -Organization- Zero passwords stored on disk
- Scoped via Entra Role or EXO Service Principal RBAC
Choosing Your Permission Model for App-Only Exchange Online PowerShell
When configuring Exchange.ManageAsApp on your Entra App Registration, you have two ways to grant administrative privileges to the Service Principal:
| Permission Method | How It Works | Best Use Case |
|---|---|---|
| Method 1: Entra ID Directory Role (e.g., Exchange Administrator or Global Reader) | Assign the Entra built-in role directly to the Enterprise Application (ServicePrincipal) | Full tenant-wide Exchange administration or read-only reporting scripts |
Method 2: Exchange Online Custom RBAC Role Group (New-ServicePrincipal + Add-RoleGroupMember) | Register the Service Principal inside EXO and assign it to a custom Role Group (e.g., only Mail Recipients or View-Only Recipients) | Least-privilege automation (e.g., an HR script that only updates user titles/mailboxes) |
Step 1: Generate a Self-Signed Certificate in Windows PowerShell
Run the following PowerShell commands on the automation server where your scheduled task runs. Setting -KeyExportPolicy NonExportable (optional) ensures that the private key cannot be exported off the machine:
# Generate a 2-year 2048-bit RSA self-signed certificate in CurrentUser\My
$Cert = New-SelfSignedCertificate `
-Subject "CN=LAB365-Dev-Automation" `
-CertStoreLocation "Cert:\CurrentUser\My" `
-KeyExportPolicy NonExportable `
-KeySpec Signature `
-KeyLength 2048 `
-HashAlgorithm SHA256 `
-NotAfter (Get-Date).AddYears(2)
# Export the public key (.cer) to upload to Microsoft Entra ID
$CerPath = ".\LAB365-Dev-Automation.cer"
Export-Certificate -Cert $Cert -FilePath $CerPath | Out-Null
[PSCustomObject]@{
Subject = $Cert.Subject
Thumbprint = $Cert.Thumbprint
NotAfter = $Cert.NotAfter
PublicCertFile = (Resolve-Path $CerPath).Path
}
Example output from our LAB 365 test environment:
Subject : CN=LAB365-Dev-Automation
Thumbprint : BEAC429DE404B46FF030811C0C9DE00DE701C027
NotAfter : 11/7/2028 11:52:54 PM
PublicCertFile : F:\dev\lab365.in\.secrets\LAB365-Dev-Automation.cer
Step 2: Automated PowerShell Script to Register the App, Upload the Cert & Grant Exchange.ManageAsApp
Instead of clicking through multiple blades in the Microsoft Entra admin center, you can run this script using the Microsoft Graph PowerShell SDK to create the App Registration, upload your .cer certificate, add the Exchange.ManageAsApp role (dc50a0fb-09a3-484d-be87-e023b12c6440 on Office 365 Exchange Online), grant Admin Consent, and assign the Exchange Administrator role:
# Connect to Microsoft Graph as Global Administrator / Privileged Role Administrator
Connect-MgGraph -Scopes "Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All", "RoleManagement.ReadWrite.Directory", "Directory.Read.All" -NoWelcome
$AppName = "LAB365-Dev-Automation"
$CerFilePath = ".\LAB365-Dev-Automation.cer"
$ExoResourceId = "00000002-0000-0ff1-ce00-000000000000" # Office 365 Exchange Online
$ManageAsAppId = "dc50a0fb-09a3-484d-be87-e023b12c6440" # Exchange.ManageAsApp
# 1. Read public certificate bytes
$CertBytes = [System.IO.File]::ReadAllBytes((Resolve-Path $CerFilePath).Path)
$CertObj = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$CertBytes)
$KeyCred = @{
type = "AsymmetricX509Cert"
usage = "Verify"
key = [Convert]::ToBase64String($CertBytes)
displayName = $AppName
startDateTime = $CertObj.NotBefore.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
endDateTime = $CertObj.NotAfter.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
}
# 2. Create the Entra App Registration requesting Exchange.ManageAsApp
$AppBody = @{
displayName = $AppName
signInAudience = "AzureADMyOrg"
keyCredentials = @($KeyCred)
requiredResourceAccess = @(
@{
resourceAppId = $ExoResourceId
resourceAccess = @(
@{ id = $ManageAsAppId; type = "Role" }
)
}
)
} | ConvertTo-Json -Depth 10
$App = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/applications" -Body $AppBody
Write-Host "[+] Created App Registration: $($App.displayName) (AppId: $($App.appId))" -ForegroundColor Green
# 3. Create the Enterprise Application (Service Principal)
$SpBody = @{ appId = $App.appId } | ConvertTo-Json
$Sp = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/servicePrincipals" -Body $SpBody
Write-Host "[+] Created Service Principal (ObjectId: $($Sp.id))" -ForegroundColor Green
# 4. Grant Admin Consent for Exchange.ManageAsApp
$ExoSp = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$ExoResourceId'").value[0]
$ConsentBody = @{
principalId = $Sp.id
resourceId = $ExoSp.id
appRoleId = $ManageAsAppId
} | ConvertTo-Json
Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($Sp.id)/appRoleAssignments" -Body $ConsentBody | Out-Null
Write-Host "[+] Granted Admin Consent for Exchange.ManageAsApp!" -ForegroundColor Green
# 5. Assign 'Exchange Administrator' Directory Role
$ExoAdminRoleId = "29232cdf-9323-42fd-ade2-1d097af3e4de"
$RoleBody = @{
"@odata.type" = "#microsoft.graph.unifiedRoleAssignment"
principalId = $Sp.id
roleDefinitionId = $ExoAdminRoleId
directoryScopeId = "/"
} | ConvertTo-Json
Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments" -Body $RoleBody | Out-Null
Write-Host "[+] Assigned Exchange Administrator role to Service Principal!" -ForegroundColor Green
Step 3: Replace -Credential in Your Scheduled PowerShell Scripts
Now replace your legacy Connect-ExchangeOnline -Credential $cred line with -AppId, -CertificateThumbprint, and -Organization (note that -Organization must be your tenant's .onmicrosoft.com domain, such as xzf58.onmicrosoft.com):
Before (Retiring in December 2026)
# ❌ LEGACY - Will fail when -Credential is removed in December 2026
$Cred = Import-Clixml "C:\Scripts\ExoAdminCred.xml"
Connect-ExchangeOnline -Credential $Cred -ShowBanner:$false
After (Modern Certificate-Based App-Only Auth)
# ✅ MODERN - Unattended Certificate-Based Authentication (EXO V3.8+)
Import-Module ExchangeOnlineManagement
$AppId = "c841ae33-4093-4385-8a90-6a530761f9b9"
$Thumbprint = "BEAC429DE404B46FF030811C0C9DE00DE701C027"
$OnMicrosoftOrg = "xzf58.onmicrosoft.com"
Connect-ExchangeOnline `
-AppId $AppId `
-CertificateThumbprint $Thumbprint `
-Organization $OnMicrosoftOrg `
-ShowBanner:$false
# For Security & Compliance PowerShell (Connect-IppsSession), use the exact same parameters:
Connect-IppsSession `
-AppId $AppId `
-CertificateThumbprint $Thumbprint `
-Organization $OnMicrosoftOrg
Step 4: Diagnostic Script — Verify Your Certificate & Exchange.ManageAsApp JWT Token Before Running EXO Cmdlets
When troubleshooting unattended authentication failures (AADSTS700027 certificate thumbprint mismatch or missing Exchange.ManageAsApp consent), run this pure-PowerShell diagnostic script. It signs a JWT client assertion using your local certificate in Cert:\CurrentUser\My, requests an Exchange Online App-Only token directly from Entra ID, and inspects the decoded roles claim:
function Test-ExoCertificateAppOnlyAuth {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[string]$ClientId,
[Parameter(Mandatory = $true)]
[string]$CertificateThumbprint
)
$Cert = Get-Item "Cert:\CurrentUser\My\$CertificateThumbprint" -ErrorAction Stop
if (-not $Cert.HasPrivateKey) {
throw "Certificate $CertificateThumbprint was found, but HasPrivateKey is FALSE!"
}
function ConvertTo-Base64Url([byte[]]$Bytes) {
[Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}
$TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
$CertHashB64Url = ConvertTo-Base64Url ($Cert.GetCertHash())
$NowEpoch = [int][double]::Parse((Get-Date -Date (Get-Date).ToUniversalTime() -UFormat %s))
$HeaderJson = (@{ alg = "RS256"; typ = "JWT"; x5t = $CertHashB64Url } | ConvertTo-Json -Compress)
$PayloadJson = (@{
aud = $TokenEndpoint
iss = $ClientId
sub = $ClientId
jti = [guid]::NewGuid().ToString()
nbf = $NowEpoch 60
exp = $NowEpoch + 600
} | ConvertTo-Json -Compress)
$JwtUnsigned = "$(ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($HeaderJson))).$(ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($PayloadJson)))"
$Rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Cert)
$SigBytes = $Rsa.SignData([Text.Encoding]::UTF8.GetBytes($JwtUnsigned), [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
$Body = @{
client_id = $ClientId
client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
client_assertion = "$JwtUnsigned.$(ConvertTo-Base64Url $SigBytes)"
scope = "https://outlook.office365.com/.default"
grant_type = "client_credentials"
}
$TokenResp = Invoke-RestMethod -Method Post -Uri $TokenEndpoint -Body $Body -ContentType "application/x-www-form-urlencoded"
# Decode JWT Payload
$PayloadB64 = $TokenResp.access_token.Split('.')[1].Replace('', '+').Replace('_', '/')
while ($PayloadB64.Length % 4 -ne 0) { $PayloadB64 += "=" }
$Claims = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($PayloadB64)) | ConvertFrom-Json
[PSCustomObject]@{
CertSubject = $Cert.Subject
CertExpires = $Cert.NotAfter
TokenAudience = $Claims.aud
AppId = $Claims.appid
ServicePrincipalId = $Claims.oid
GrantedAppRoles = ($Claims.roles -join ", ")
HasExchangeManageApp = ($Claims.roles -contains "Exchange.ManageAsApp")
}
}
# Live-Tested Output on LAB 365 Tenant:
# Test-ExoCertificateAppOnlyAuth -TenantId "ab85c7a1-366d-44ba-acc6-924fa0950045" -ClientId "c841ae33-4093-4385-8a90-6a530761f9b9" -CertificateThumbprint "BEAC429DE404B46FF030811C0C9DE00DE701C027"
Verified output from the LAB 365 tenant:
CertSubject : CN=LAB365-Dev-Automation
CertExpires : 11/7/2028 11:52:54 PM
TokenAudience : https://outlook.office365.com
AppId : c841ae33-4093-4385-8a90-6a530761f9b9
ServicePrincipalId : c142386f-63d7-4b1c-b2b5-ec5e9c02ab43
GrantedAppRoles : Exchange.ManageAsApp
HasExchangeManageApp : True