With Basic Authentication deprecated and Exchange Online's legacy ROPC flows retiring, enterprise applications, scanners, monitoring alerts, and PowerShell automation scripts need a secure, modern way to send authenticated SMTP messages (smtp.office365.com:587) without interactive user sign-ins.
Microsoft provides two primary App-Only options for sending email in Microsoft 365:
- Microsoft Graph
Mail.SendApplication Permission: Allows the app to send mail via HTTPS REST—but by default, an app grantedMail.Sendcan send as any mailbox in the entire tenant unless restricted by an Application Access Policy (now being replaced by Role-Based Access Control for Applications). - Exchange Online
SMTP.SendAsApp(OAuth 2.0 Client Credentials Flow for SMTP AUTH): A least-privilege design! Even after you grant Admin Consent forSMTP.SendAsAppin Microsoft Entra ID, the application cannot send email as anyone until an Exchange Administrator explicitly registers the Service Principal in Exchange Online (New-ServicePrincipal) and grants itFullAccesspermission on the specific sender mailbox (Add-MailboxPermission). Furthermore, messages sent viaSMTP.SendAsAppare automatically saved in the sender mailbox's Sent Items folder.
SMTP.SendAsApp Architecture & Security Model
- API:
Office 365 Exchange Online - App Permission:
SMTP.SendAsApp - Admin Consent granted
New-ServicePrincipalregisters AppId & ObjectIdAdd-MailboxPermission -AccessRights FullAccessscopes exact sender mailbox
- Acquires token for
https://outlook.office365.com/.default - Authenticates via SASL
XOAUTH2 - Saves copy in Sent Items
Comparison: SMTP.SendAsApp vs. Graph Mail.Send
| Feature | Exchange Online SMTP.SendAsApp | Microsoft Graph Mail.Send (App-Only) |
|---|---|---|
| Protocol | Standard SMTP TLS (smtp.office365.com:587 with XOAUTH2) | HTTPS REST (POST /v1.0/users/{id}/sendMail) |
| Default Blast Radius | Zero — Cannot send as any mailbox until granted FullAccess per mailbox | Tenant-Wide — Can send as any user unless scoped via EXO Application RBAC |
| Saved in Sent Items? | Yes (Automatically saved by Exchange SMTP submission engine) | Configurable (saveToSentItems: true) |
| Legacy App Compatibility | Ideal for SMTP-based apps, Ticketing systems, & PowerShell | Ideal for cloud-native REST integrations |
Step 1: Register the Entra App & Grant SMTP.SendAsApp
- Sign in to the Microsoft Entra admin center (
https://entra.microsoft.com) and go to Identity → Applications → App registrations → + New registration. - Name the application (for example,
LAB365-SMTP-Relay) and click Register. - Go to API permissions → + Add a permission → APIs my organization uses.
- Search for
Office 365 Exchange Online(AppId00000002-0000-0ff1-ce00-000000000000) and select Application permissions. - Expand SMTP, check
SMTP.SendAsApp, and click Add permissions. - Click Grant admin consent for [Your Tenant].
- Under Certificates & secrets, upload a certificate (recommended for production) or create a Client Secret.
Step 2: Register the Service Principal in Exchange Online & Grant Mailbox Permission
Important 2026 Update: The legacyGet-AzureADServicePrincipalcmdlet from theAzureADmodule is retired. Always retrieve your Enterprise Application'sAppIdandId(ObjectId) using the Microsoft Graph PowerShell SDK (Get-MgServicePrincipal)!
⚠️ Common Pitfall: In Microsoft Entra ID, the App Registration blade and the Enterprise Applications (Service Principal) blade have different Object IDs! When running New-ServicePrincipal in Exchange Online, you must pass the Id (ObjectId) from Enterprise Applications (Get-MgServicePrincipal), never the Object ID from the App Registration blade.
# 1. Connect to Microsoft Graph & Exchange Online
Connect-MgGraph -Scopes "Application.Read.All" -NoWelcome
Connect-ExchangeOnline -ShowBanner:$false
# 2. Fetch the Enterprise Application (Service Principal) details via Microsoft Graph
$AppDisplayName = "LAB365-SMTP-Relay"
$EntraSp = Get-MgServicePrincipal -Filter "displayName eq '$AppDisplayName'"
if (-not $EntraSp) {
throw "Service Principal '$AppDisplayName' not found in Entra ID!"
}
Write-Host "Found Service Principal -> AppId: $($EntraSp.AppId) | Enterprise App ObjectId: $($EntraSp.Id)" -ForegroundColor Cyan
# 3. Register the Service Principal inside Exchange Online
New-ServicePrincipal -AppId $EntraSp.AppId `
-ObjectId $EntraSp.Id `
-DisplayName $EntraSp.DisplayName
# 4. Grant FullAccess permission on the sender mailbox to the Service Principal
$SenderMailbox = "[email protected]"
Add-MailboxPermission -Identity $SenderMailbox `
-User $EntraSp.Id `
-AccessRights FullAccess
# 5. Ensure Authenticated SMTP (SmtpClientAuthenticationDisabled) is enabled on the sender mailbox
Set-CASMailbox -Identity $SenderMailbox -SmtpClientAuthenticationDisabled:$false
Step 3: Send Email via OAuth 2.0 XOAUTH2 in PowerShell (Zero External Module Dependencies)
Once permissions propagate (typically 15–30 minutes in Exchange Online), you can acquire an OAuth 2.0 access token for the scope https://outlook.office365.com/.default and authenticate against smtp.office365.com:587 using the SASL XOAUTH2 mechanism.
Here is a complete, self-contained PowerShell function that acquires the OAuth 2.0 token (supporting either Certificate Auth or Client Secret), verifies that the token contains the SMTP.SendAsApp role claim, constructs the XOAUTH2 base64 payload, and sends the email over TLS 1.2/1.3:
function Send-ExoMailMessageOAuth2 {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[string]$ClientId,
[Parameter(Mandatory = $true)]
[string]$ClientSecret,
[Parameter(Mandatory = $true)]
[string]$From,
[Parameter(Mandatory = $true)]
[string]$To,
[Parameter(Mandatory = $true)]
[string]$Subject,
[Parameter(Mandatory = $true)]
[string]$Body
)
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# 1. Request OAuth 2.0 Access Token for Exchange Online
$TokenUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
$TokenBody = @{
client_id = $ClientId
client_secret = $ClientSecret
scope = "https://outlook.office365.com/.default"
grant_type = "client_credentials"
}
$TokenResponse = Invoke-RestMethod -Method Post -Uri $TokenUri -Body $TokenBody -ContentType "application/x-www-form-urlencoded"
$AccessToken = $TokenResponse.access_token
# 2. Verify JWT Token Contains 'SMTP.SendAsApp' Role Claim
$PayloadPart = $AccessToken.Split('.')[1].Replace('', '+').Replace('_', '/')
while ($PayloadPart.Length % 4 -ne 0) { $PayloadPart += "=" }
$JwtClaims = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($PayloadPart)) | ConvertFrom-Json
if ($JwtClaims.roles -notcontains "SMTP.SendAsApp") {
throw "Access token does NOT contain 'SMTP.SendAsApp' in roles claim! Current roles: $($JwtClaims.roles -join ', ')"
}
Write-Host "[+] Verified JWT Role Claim: SMTP.SendAsApp (Token Audience: $($JwtClaims.aud))" -ForegroundColor Green
# 3. Build SASL XOAUTH2 String: "user=" + User + "^Aauth=Bearer " + Token + "^A^A"
$CtrlA = [char]1
$XOAuth2Raw = "user=$From${CtrlA}auth=Bearer $AccessToken${CtrlA}${CtrlA}"
$XOAuth2B64 = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($XOAuth2Raw))
# 4. Connect to smtp.office365.com:587, Upgrade to STARTTLS, & Authenticate via XOAUTH2
$TcpClient = New-Object System.Net.Sockets.TcpClient("smtp.office365.com", 587)
$NetStream = $TcpClient.GetStream()
$Reader = New-Object System.IO.StreamReader($NetStream)
$Writer = New-Object System.IO.StreamWriter($NetStream)
$Writer.AutoFlush = $true
Write-Host "[*] Server Banner: $($Reader.ReadLine())" -ForegroundColor DarkGray
$Writer.WriteLine("EHLO lab365.in")
while (($Line = $Reader.ReadLine()) -and $Line.Substring(3, 1) -eq "-") {}
$Writer.WriteLine("STARTTLS")
$StartTlsResp = $Reader.ReadLine()
if (-not $StartTlsResp.StartsWith("220")) {
throw "Failed to initiate STARTTLS: $StartTlsResp"
}
$SslStream = New-Object System.Net.Security.SslStream($NetStream, $false)
$SslStream.AuthenticateAsClient("smtp.office365.com")
$SslReader = New-Object System.IO.StreamReader($SslStream)
$SslWriter = New-Object System.IO.StreamWriter($SslStream)
$SslWriter.AutoFlush = $true
$SslWriter.WriteLine("EHLO lab365.in")
while (($Line = $SslReader.ReadLine()) -and $Line.Substring(3, 1) -eq "-") {}
$SslWriter.WriteLine("AUTH XOAUTH2 $XOAuth2B64")
$AuthResp = $SslReader.ReadLine()
if (-not $AuthResp.StartsWith("235")) {
throw "SMTP XOAUTH2 Authentication Failed: $AuthResp"
}
Write-Host "[+] SMTP Authentication Successful: $AuthResp" -ForegroundColor Green
# 5. Transmit Message
$SslWriter.WriteLine("MAIL FROM:<$From>")
[void]$SslReader.ReadLine()
$SslWriter.WriteLine("RCPT TO:<$To>")
[void]$SslReader.ReadLine()
$SslWriter.WriteLine("DATA")
[void]$SslReader.ReadLine()
$SslWriter.WriteLine("From: $From")
$SslWriter.WriteLine("To: $To")
$SslWriter.WriteLine("Subject: $Subject")
$SslWriter.WriteLine("Content-Type: text/html; charset=utf-8")
$SslWriter.WriteLine("")
$SslWriter.WriteLine($Body)
$SslWriter.WriteLine(".")
$DataResp = $SslReader.ReadLine()
Write-Host "[+] Message Sent: $DataResp" -ForegroundColor Green
$SslWriter.WriteLine("QUIT")
$TcpClient.Close()
}
# Example Usage:
# Send-ExoMailMessageOAuth2 `
# -TenantId "ab85c7a1-366d-44ba-acc6-924fa0950045" `
# -ClientId "c841ae33-4093-4385-8a90-6a530761f9b9" `
# -ClientSecret "YOUR_CLIENT_SECRET" `
# -From "[email protected]" `
# -To "[email protected]" `
# -Subject "Test OAuth2 SMTP.SendAsApp Relay" `
# -Body "<h3>Sent via Exchange Online SMTP OAuth 2.0 Client Credentials Flow</h3>"
Troubleshooting Common 535 5.7.3 Authentication unsuccessful Errors
If AUTH XOAUTH2 returns 535 5.7.3 Authentication unsuccessful:
- Wrong
ObjectIdpassed toNew-ServicePrincipal: Verify that(Get-ServicePrincipal -Identity $AppDisplayName).ObjectIdmatches(Get-MgServicePrincipal -Filter "displayName eq '$AppDisplayName'").Id(the Enterprise Application Object ID, not the App Registration Object ID). - SMTP AUTH Disabled on the Mailbox or Tenant: Run
Get-CASMailbox -Identity $SenderMailbox | Select SmtpClientAuthenticationDisabled. Remember that$nullinherits the tenant-levelGet-TransportConfigsetting, so explicitly set-SmtpClientAuthenticationDisabled:$falseon the sender mailbox. - Wrong OAuth Scope: Ensure your token request specifies
https://outlook.office365.com/.default(nothttps://graph.microsoft.com/.default).