Preparing for the December 2026 Connect-ExchangeOnline -Credential Retirement: Migrate Scheduled Scripts to Certificate-Based App-Only Auth

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

Exchange Online Unattended PowerShell Authentication Migration
Retiring December 2026
Legacy ROPC (-Credential)
  • Connect-ExchangeOnline -Credential $Cred
  • Requires MFA exclusion on service account
  • Blocked by MSAL & Security Defaults
Step 1: Local Key Store
2048-Bit RSA Certificate
  • Private key locked in Cert:\CurrentUser\My
  • Public .cer uploaded to Entra App
  • Signs RFC 7523 JWT client_assertion
Step 2: Unattended EXO V3
Exchange.ManageAsApp
  • 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 MethodHow It WorksBest 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:

POWERSHELL
# 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:

TEXT
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:

POWERSHELL
# 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)

POWERSHELL
# ❌ 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)

POWERSHELL
# ✅ 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:

POWERSHELL
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:

TEXT
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