Saturday, August 8, 2026

How to Set Up a Vanity Email Address in Microsoft 365 (Exchange Online)

How to Set Up a Vanity Email Address in Microsoft 365 (Exchange Online)

A vanity email address is a friendly, memorable address — like hello@contoso.com or sales@contoso.com — that delivers mail into an existing mailbox or group instead of having a mailbox of its own. This guide covers every way to set one up in Exchange Online, how to test it, common errors, and the edge cases (send-as, external targets, new domains).

All names, domains, and addresses in this article are examples.


How It Works

The cleanest way to implement a vanity address is not forwarding at all. If the address is on a domain your tenant already owns, you simply add it as a secondary SMTP proxy address (alias) on the target object. Exchange then delivers mail sent to the alias natively — no extra hop, no rule to maintain, nothing to break later.

Which cmdlet you use depends on what kind of object the target is, so identifying that is always the first step.

Before you start, decide three things:

  1. The vanity address — and whether its domain is already an accepted domain in the tenant.
  2. The target — the existing mailbox or group it should deliver to.
  3. Incoming only, or send-as too? An alias handles incoming mail only. If outgoing mail must also show the vanity address as the sender, see the Send-As section — it changes the design.

Step 1: Connect and Verify

Connect-ExchangeOnline

Check the vanity address is not already in use. Email addresses must be unique across the entire tenant — mailboxes, groups, contacts, everything:

Get-Recipient hello@contoso.com

You want this to fail:

Get-Recipient: The operation couldn't be performed because object
'hello@contoso.com' couldn't be found on '<server>'.

"Couldn't be found" means the address is free. If an object comes back instead, stop and investigate what owns it.

Identify what the target is:

Get-Recipient support-team@contoso.com | fl Name,RecipientTypeDetails

The RecipientTypeDetails value decides everything:

RecipientTypeDetailsObject typeCmdlet to use
MailUniversalDistributionGroupDistribution listSet-DistributionGroup
GroupMailboxMicrosoft 365 GroupSet-UnifiedGroup
SharedMailboxShared mailboxSet-Mailbox
UserMailboxA person's mailboxSet-Mailbox
DynamicDistributionGroupDynamic DLSet-DynamicDistributionGroup
MailContact / MailUserExternal-pointing objectSee "External Target" below

Tip: don't rely on the plain Get-Recipient table output — its RecipientType column shows UserMailbox even for shared mailboxes. Always check RecipientTypeDetails.


Step 2: Add the Vanity Address as an Alias

Pick the command that matches the object type:

# If it's a Distribution List:
Set-DistributionGroup -Identity "support-team@contoso.com" -EmailAddresses @{add="hello@contoso.com"}

# If it's a Microsoft 365 Group:
Set-UnifiedGroup -Identity "support-team@contoso.com" -EmailAddresses @{add="hello@contoso.com"}

# If it's a shared mailbox (or a user mailbox):
Set-Mailbox -Identity "support-team@contoso.com" -EmailAddresses @{add="hello@contoso.com"}

# If it's a dynamic distribution group:
Set-DynamicDistributionGroup -Identity "support-team@contoso.com" -EmailAddresses @{add="hello@contoso.com"}

Prefer the GUI? Exchange Admin Center → Recipients → (Mailboxes or Groups) → select the object → Email addresses → Add email address type → enter the alias → Save.

Verify it took:

Get-Mailbox support-team@contoso.com | fl EmailAddresses
# (or Get-DistributionGroup / Get-UnifiedGroup, matching the object type)

Expected output:

EmailAddresses : {smtp:hello@contoso.com, SMTP:support-team@contoso.com,
                 smtp:support-team@contoso.onmicrosoft.com}

Read the prefixes carefully:

  • SMTP: (uppercase) = the primary address. Outgoing mail and replies are stamped with this. Adding an alias never changes it.
  • smtp: (lowercase) = a secondary alias. It receives mail, nothing more.

That gives exactly the classic vanity behavior: mail to hello@ lands in support-team@, and replies still come from support-team@.


Gotcha: The GetResponseHeader Error

You may hit this on Set-Mailbox (or any EXO cmdlet):

Exception: Method invocation failed because [System.Net.Http.HttpResponseMessage]
does not contain a method named 'GetResponseHeader'.

This is a known bug in older ExchangeOnlineManagement module versions on PowerShell 7 — the module's internal retry/error-handling path calls a method that no longer exists in modern .NET. It is not a problem with your command, and sometimes the change even applies before the module trips over itself.

What to do, in order:

# 1. Check whether the change actually applied despite the error:
Get-Mailbox support-team@contoso.com | fl EmailAddresses

# 2. If not, reconnect (stale tokens are a common trigger) and retry:
Disconnect-ExchangeOnline -Confirm:$false
Connect-ExchangeOnline
Set-Mailbox -Identity "support-team@contoso.com" -EmailAddresses @{add="hello@contoso.com"}

# 3. Still failing? Update the module and retry in a FRESH window:
Update-Module ExchangeOnlineManagement -Force

A reconnect-and-retry usually fixes it. The Exchange Admin Center is also a perfectly good fallback — no module bug there.


Step 3: Test the Delivery

Send a test email to the new vanity address, then prove delivery with a message trace — no mailbox access required:

Get-MessageTraceV2 -RecipientAddress support-team@contoso.com `
    -StartDate (Get-Date).AddHours(-1) -EndDate (Get-Date) |
    fl Received,SenderAddress,RecipientAddress,Subject,Status

Expected result:

Received         : 8/7/2026 8:21:00 PM
SenderAddress    : megan.bowen@contoso.com
RecipientAddress : support-team@contoso.com
Subject          : Vanity alias test
Status           : Delivered

Three testing gotchas:

  1. Get-MessageTrace is deprecated (retiring from September 2025) — use Get-MessageTraceV2.
  2. Search by the primary address, not the alias. The trace logs messages against the mailbox's primary SMTP address, so searching -RecipientAddress hello@contoso.com returns nothing even when delivery succeeded. Searching by -SenderAddress <your address> also works.
  3. Trace data lags 5–15 minutes behind real delivery. An empty result right after sending doesn't mean failure — wait and re-run. If it's still empty after 15+ minutes, check the sender's inbox for a bounce (NDR).

Status: Delivered = the alias works end to end.


Send-As: When Outgoing Mail Must Show the Vanity Address

An alias only handles incoming mail. By default, Exchange Online stamps all outgoing mail with the mailbox's primary address. If mail must go out as hello@contoso.com, choose one of two designs.

Option 1 — Keep the alias, enable send-from-alias (tenant-wide)

# Check whether it's already on:
Get-OrganizationConfig | fl SendFromAliasEnabled

# Grant Send As on the mailbox to the users who need it:
Add-RecipientPermission -Identity "support-team@contoso.com" `
    -Trustee "megan.bowen@contoso.com" -AccessRights SendAs

# Enable sending from aliases — ORG-WIDE setting:
Set-OrganizationConfig -SendFromAliasEnabled $true

Users then show the From field in Outlook/OWA and type the alias manually.

⚠️ Caution: SendFromAliasEnabled affects the entire tenant — every user becomes able to send from their aliases too. Treat it as an organizational change, not a quick fix.

Option 2 — Promote the vanity address to its own shared mailbox (scoped, cleaner)

No tenant-wide change; incoming behavior stays identical:

# 1. Remove the alias first (addresses must be unique tenant-wide):
Set-Mailbox -Identity "support-team@contoso.com" -EmailAddresses @{remove="hello@contoso.com"}

# 2. Create the vanity address as its own shared mailbox:
New-Mailbox -Shared -Name "Hello" -PrimarySmtpAddress "hello@contoso.com"

# 3. Forward everything to the real mailbox (no copy kept in the new one):
Set-Mailbox -Identity "hello@contoso.com" `
    -ForwardingAddress "support-team@contoso.com" -DeliverToMailboxAndForward $false

# 4. Grant Send As (and Full Access, if users will open it) to specific users:
Add-RecipientPermission -Identity "hello@contoso.com" `
    -Trustee "megan.bowen@contoso.com" -AccessRights SendAs
Add-MailboxPermission -Identity "hello@contoso.com" `
    -User "megan.bowen@contoso.com" -AccessRights FullAccess -AutoMapping $true

Notes: shared mailboxes need no license under 50 GB (a license is required only for archive, litigation hold, or >50 GB). There's a brief cutover between removing the alias and creating the mailbox — do it in a quiet window.

Rule of thumb: start with the plain alias. Only restructure to Option 2 if send-as is a confirmed requirement — it's scoped, reversible, and doesn't touch tenant config.


External Target: Forwarding Outside the Tenant

If the destination lives outside your organization (e.g. partners@fabrikam.com), an alias can't work — aliases only attach to objects in your own tenant. Instead:

# 1. Create a mail contact for the external address:
New-MailContact -Name "Partners (External)" -ExternalEmailAddress "partners@fabrikam.com"

# 2. Create the vanity address as a shared mailbox and forward it:
New-Mailbox -Shared -Name "Hello" -PrimarySmtpAddress "hello@contoso.com"
Set-Mailbox -Identity "hello@contoso.com" `
    -ForwardingAddress "partners@fabrikam.com" -DeliverToMailboxAndForward $true

⚠️ Check your outbound spam policy: automatic external forwarding is blocked by default in many tenants (Automatic forwarding: Off in the anti-spam outbound policy). You may need an explicit policy exception. Keeping -DeliverToMailboxAndForward $true retains a copy in the shared mailbox, which helps with compliance and troubleshooting.


New Domain: When the Vanity Domain Isn't in the Tenant Yet

If the address is on a domain the tenant doesn't own yet (e.g. hello@brand-new-product.com), everything above is blocked until the domain is onboarded:

  1. Microsoft 365 admin center → Settings → Domains → Add domain, verify ownership via a DNS TXT record.
  2. Publish MX, SPF, DKIM, and DMARC records for the new domain.
  3. Confirm it appears as an accepted domain: Get-AcceptedDomain.
  4. Then proceed with the alias or shared-mailbox setup above.

Adding a domain touches DNS ownership and mail routing — plan it as its own piece of work.


Rollback

Undoing the plain-alias setup is one line:

Set-Mailbox -Identity "support-team@contoso.com" -EmailAddresses @{remove="hello@contoso.com"}

(Use the matching Set-DistributionGroup / Set-UnifiedGroup variant for groups.)


Checklist / TL;DR

  •  Decide: vanity address, target object, is send-as needed, is the domain in the tenant
  •  Get-Recipient <vanity> → must return not found (address is free)
  •  Get-Recipient <target> | fl RecipientTypeDetails → pick the matching Set-* cmdlet
  •  Add the alias with -EmailAddresses @{add="..."} — lowercase smtp: = secondary, primary stays untouched
  •  GetResponseHeader exception? → check if it applied, reconnect, update the module, or use the EAC
  •  Test with Get-MessageTraceV2 — search the primary address, allow 5–15 min lag
  •  Send-as needed? → prefer a dedicated shared mailbox over the tenant-wide SendFromAliasEnabled flag
  •  External destination? → mail contact + forwarding + outbound-spam policy check
  •  New domain? → verify domain and publish MX/SPF/DKIM/DMARC first
  •  Keep the one-line rollback handy

Wednesday, August 5, 2026

Never Get Surprised by an Expired App Secret Again: Building an Automated Expiry Monitor with Azure Automation and Power Automate

Never Get Surprised by an Expired App Secret Again: Building an Automated Expiry Monitor with Azure Automation and Power Automate

How we built a fully automated monitor that scans thousands of Azure app registrations, finds every secret and certificate about to expire, looks up who owns each app, and delivers a clean HTML report to the inbox — every day, hands-free.


The Problem

If you run a Microsoft Entra ID (Azure AD) tenant of any real size, you know this pain: an application secret or certificate silently expires, an integration breaks at 2 AM, and nobody knows who owns the app that just took down a business process.

In our tenant the scale made manual tracking impossible:

  • 321 App Registrations
  • 4,321 Enterprise Apps (Service Principals)
  • 4,321 credentials flagged on the very first scan — over 1,300 of them already expired

We needed a system that continuously answers three questions: what is expiring, when, and who owns it — without anyone having to remember to check.

The Architecture

The solution has two halves, glued together by a simple JSON payload:

┌─────────────────────────────┐         ┌──────────────────────────────┐
│  Azure Automation Runbook   │  HTTPS  │  Power Automate Cloud Flow   │
│  (PowerShell 7.2)           │ ──────► │  (HTTP Request trigger)      │
│                             │  POST   │                              │
│  1. Auth via Managed Id.    │  JSON   │  1. Receive JSON payload     │
│  2. Query Graph API         │         │  2. Create HTML table        │
│  3. Evaluate expiry dates   │         │  3. Send formatted email     │
│  4. Look up app owners      │         │                              │
│  5. Trigger the flow        │         │                              │
└─────────────────────────────┘         └──────────────────────────────┘

Why split it this way? The runbook is great at heavy lifting (thousands of Graph API calls, filtering, paging), while Power Automate is great at the last mile (beautiful HTML email, easy to re-style without touching code, easy to extend to Teams later).

Prerequisites

  1. An Azure Automation Account with System-Assigned Managed Identity enabled.
  2. The managed identity granted these Microsoft Graph application roles:
    • Application.Read.All
    • Directory.Read.All
  3. A Power Automate flow with the "When an HTTP request is received" trigger.

Note on permissions: the solution is strictly read-only against Entra ID. It never modifies an app registration — it only reads credential metadata and the owners list.

The Complete Runbook

Here is the full runbook in one place. All identifiers below are synthetic — replace the subscription ID, flow URL, and recipient with your own values.

# ============================================================
# Runbook : AppReg-Secret-Expiry-Monitor
# Purpose : Monitor App Registration & Enterprise App
#           secret/certificate expiry and email a report
# Runtime : PowerShell 7.2 (Azure Automation)
# ============================================================
# PRE-REQUISITE: System-assigned Managed Identity must be
# ENABLED on the Automation Account and granted these Graph
# API app roles in Entra ID:
#   - Application.Read.All
#   - Directory.Read.All
# ============================================================

Write-Output "========================================"
Write-Output "AppReg Secret/Cert Expiry Monitor"
Write-Output "Runbook started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================"

# --- Config ---
$ThresholdDays  = @(60, 30, 7)
$GraphBaseUrl   = "https://graph.microsoft.com/v1.0"
$AlertRecipient = "iam-alerts@contoso.com"

# The HTTP POST URL copied from your Power Automate trigger card.
# It contains its own SAS key (sig=...) - treat it like a password!
$FlowInvokeUri  = "https://contoso00000000000000000000.00.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/0000000000000000000000000000abcd/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=<YOUR-SAS-SIGNATURE>"

Write-Output "Alert thresholds : $($ThresholdDays -join ', ') days"

# ============================================================
# STEP 1 : Authenticate to Azure & Microsoft Graph
# ============================================================
Write-Output "[Step 1] Connecting with System-Assigned Managed Identity..."
Connect-AzAccount -Identity -ErrorAction Stop
Write-Output "[Step 1] Azure login successful."

Write-Output "[Step 1] Requesting Graph API access token..."
$graphTokenObj = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/" -ErrorAction Stop
$GraphHeaders  = @{
    "Authorization" = "Bearer $($graphTokenObj.Token)"
    "Content-Type"  = "application/json"
}

# Quick verification call + tenant name for the report
$org        = Invoke-RestMethod -Uri "$GraphBaseUrl/organization" -Headers $GraphHeaders -Method Get
$tenantName = $org.value[0].displayName
Write-Output "[Step 1] Connected to tenant : $tenantName"

# ============================================================
# STEP 2 : Fetch all App Registrations and Enterprise Apps
#          (paged - Graph returns max 100-999 items per page)
# ============================================================
function Get-AllGraphPages {
    param([string]$Uri, [hashtable]$Headers)
    $items = [System.Collections.Generic.List[object]]::new()
    do {
        $resp = Invoke-RestMethod -Uri $Uri -Headers $Headers -Method Get -ErrorAction Stop
        foreach ($i in $resp.value) { $items.Add($i) }
        $Uri = $resp.'@odata.nextLink'
    } while ($Uri)
    return $items
}

Write-Output "[Step 2] Fetching App Registrations..."
$appRegUri = "$GraphBaseUrl/applications?`$select=id,displayName,appId,passwordCredentials,keyCredentials"
$appRegs   = Get-AllGraphPages -Uri $appRegUri -Headers $GraphHeaders
Write-Output "[Step 2] App Registrations found : $($appRegs.Count)"

Write-Output "[Step 2] Fetching Enterprise Apps (Service Principals)..."
$spUri = "$GraphBaseUrl/servicePrincipals?`$select=id,displayName,appId,passwordCredentials,keyCredentials"
$servicePrincipals = Get-AllGraphPages -Uri $spUri -Headers $GraphHeaders
Write-Output "[Step 2] Enterprise Apps found    : $($servicePrincipals.Count)"

# ============================================================
# STEP 3 : Evaluate credential expiry dates
#          + look up owners ONLY for apps that matter
# ============================================================
$Today      = (Get-Date).Date
$AlertItems = [System.Collections.Generic.List[object]]::new()

function Invoke-ExpiryCheck {
    param($Objects, [string]$ObjectType, $Thresholds, $Today, $Results)

    foreach ($obj in $Objects) {
        $name  = $obj.displayName
        $appId = $obj.appId

        # Owners are fetched LAZILY - only when this app has a
        # credential expiring within the alert window. This avoids
        # thousands of unnecessary Graph calls per run.
        $ownerEmails = $null

        foreach ($credSet in @(
            @{ Creds = $obj.passwordCredentials; Type = "Secret" },
            @{ Creds = $obj.keyCredentials;      Type = "Certificate" }
        )) {
            foreach ($cred in $credSet.Creds) {
                if (-not $cred.endDateTime) { continue }
                $expiry       = [datetime]$cred.endDateTime
                $daysLeft     = ($expiry.Date - $Today).Days
                $hitThreshold = $Thresholds | Where-Object { $daysLeft -le $_ } |
                                Sort-Object | Select-Object -First 1
                if ($null -ne $hitThreshold) {

                    # Owner lookup (read-only) - only for credentials
                    # expiring within 60 days, once per app
                    if ($daysLeft -ge 0 -and $null -eq $ownerEmails) {
                        $ownerEndpoint = if ($ObjectType -eq "AppReg") { "applications" }
                                         else { "servicePrincipals" }
                        $ownerEmails = "No owner assigned"
                        try {
                            $ownersResp = Invoke-RestMethod `
                                -Uri "$GraphBaseUrl/$ownerEndpoint/$($obj.id)/owners" `
                                -Headers $GraphHeaders -Method Get -ErrorAction Stop
                            $emails = $ownersResp.value | ForEach-Object {
                                if ($_.mail) { $_.mail } else { $_.userPrincipalName }
                            } | Where-Object { $_ }
                            if ($emails) { $ownerEmails = ($emails -join "; ") }
                        } catch { $ownerEmails = "Error fetching owners" }
                    }

                    $Results.Add(@{
                        ObjectType = $ObjectType; AppName = $name; AppId = $appId
                        CredType   = $credSet.Type
                        CredName   = $cred.displayName; CredId = $cred.keyId
                        ExpiryDate = $expiry.ToString("yyyy-MM-dd")
                        DaysLeft   = $daysLeft; Threshold = $hitThreshold
                        IsExpired  = ($daysLeft -lt 0); Owners = $ownerEmails
                    })
                }
            }
        }
    }
}

Write-Output "[Step 3] Evaluating credential expiry dates..."
Invoke-ExpiryCheck -Objects $appRegs           -ObjectType "AppReg" `
    -Thresholds $ThresholdDays -Today $Today -Results $AlertItems
Invoke-ExpiryCheck -Objects $servicePrincipals -ObjectType "EntApp" `
    -Thresholds $ThresholdDays -Today $Today -Results $AlertItems

Write-Output "[Step 3] Total credentials flagged : $($AlertItems.Count)"

# ============================================================
# STEP 4 : Summarize
# ============================================================
$expired  = @($AlertItems | Where-Object { $_.IsExpired })
$within7  = @($AlertItems | Where-Object { -not $_.IsExpired -and $_.DaysLeft -le 7 })
$within30 = @($AlertItems | Where-Object { -not $_.IsExpired -and $_.DaysLeft -le 30 -and $_.DaysLeft -gt 7 })
$within60 = @($AlertItems | Where-Object { -not $_.IsExpired -and $_.DaysLeft -le 60 -and $_.DaysLeft -gt 30 })
$allWithin60 = @($AlertItems | Where-Object { -not $_.IsExpired -and $_.DaysLeft -le 60 })

Write-Output ""
Write-Output "[Step 4] Expiry breakdown:"
Write-Output "  Already EXPIRED       : $($expired.Count)"
Write-Output "  Expiring <=  7 days   : $($within7.Count)"
Write-Output "  Expiring  8-30 days   : $($within30.Count)"
Write-Output "  Expiring 31-60 days   : $($within60.Count)"

$actionable = @($AlertItems | Where-Object { -not $_.IsExpired } | Sort-Object { $_.DaysLeft })
$topItems   = $actionable | Select-Object -First 20 | ForEach-Object {
    "$($_.ObjectType) | $($_.AppName) | $($_.CredType) | $($_.ExpiryDate) (expires in $($_.DaysLeft)d)"
}

# ============================================================
# STEP 5 : Trigger the Power Automate flow
# ============================================================
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"

$body = @{
    recipient      = $AlertRecipient
    subject        = "[$tenantName] App Credential Expiry Alert - $($actionable.Count) expiring within 60 days"
    timestamp      = $timestamp
    tenant         = $tenantName
    totalFlagged   = $AlertItems.Count
    alreadyExpired = $expired.Count
    expiringIn7d   = $within7.Count
    expiringIn30d  = $within30.Count
    expiringIn60d  = $allWithin60.Count
    credentials    = @($allWithin60 | Select-Object ObjectType, AppName, AppId,
                        CredType, CredName, ExpiryDate, DaysLeft, Threshold, Owners)
    topItems       = ($topItems -join "`n")
} | ConvertTo-Json -Depth 5

Write-Output "[Step 5] Triggering Power Automate flow for $AlertRecipient..."
try {
    # IMPORTANT: the URL already carries a SAS signature (sig=...),
    # so DO NOT add an Authorization header - Logic Apps rejects
    # requests that carry two authentication schemes at once.
    Invoke-RestMethod -Uri $FlowInvokeUri -Method Post `
        -Body $body -ContentType "application/json" -ErrorAction Stop
    Write-Output "[Step 5] Flow triggered successfully - email will be dispatched by Power Automate."
}
catch {
    Write-Error "[Step 5] Flow trigger failed: $($_.Exception.Message)"
    throw
}

Write-Output ""
Write-Output "=== Runbook complete ==="
Write-Output "Scan finished: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"

The Power Automate Flow

The flow is intentionally tiny — three steps:

1. Trigger — "When an HTTP request is received"

Paste this request body JSON schema (generated from a sample payload):

{
    "type": "object",
    "properties": {
        "recipient":      { "type": "string" },
        "subject":        { "type": "string" },
        "timestamp":      { "type": "string" },
        "tenant":         { "type": "string" },
        "totalFlagged":   { "type": "integer" },
        "alreadyExpired": { "type": "integer" },
        "expiringIn7d":   { "type": "integer" },
        "expiringIn30d":  { "type": "integer" },
        "expiringIn60d":  { "type": "integer" },
        "topItems":       { "type": "string" },
        "credentials": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "ObjectType": { "type": "string" },
                    "AppName":    { "type": "string" },
                    "AppId":      { "type": "string" },
                    "CredType":   { "type": "string" },
                    "CredName":   { "type": ["string", "null"] },
                    "ExpiryDate": { "type": "string" },
                    "DaysLeft":   { "type": "integer" },
                    "Threshold":  { "type": "integer" },
                    "Owners":     { "type": ["string", "null"] }
                }
            }
        }
    }
}

After you save the flow once, the trigger card displays the HTTP POST URL — that exact URL (including its sig= SAS key) is what goes into $FlowInvokeUri in the runbook.

2. Create HTML table

  • From: triggerBody()?['credentials']
  • Columns: automatic — the table picks up ObjectType, AppName, CredType, ExpiryDate, DaysLeft, and the new Owners column by itself.

3. Send an email (V2)

  • To: triggerBody()?['recipient']
  • Subject: triggerBody()?['subject']
  • Body: summary counts (expiringIn7d, expiringIn30d, expiringIn60d, alreadyExpired) followed by the HTML table output, with a little inline CSS to make the table presentable.

That's the whole flow. The email goes to a single distribution list — the app owners appear as information in a column, they are not recipients. (Per-owner notification is a clean future extension: loop over the credentials array, group by Owners, send each person only their own rows.)

The Complete Power Automate Flow Definition

If you prefer to build the flow from code instead of clicking through the designer, here is the complete flow definition. You can paste it via the Power Automate "Edit in code view" experience (or adapt it for a Logic Apps deployment). As everywhere in this article, all identifiers are synthetic — the connection reference GUID is generated for your environment automatically when you sign in to the Office 365 Outlook connector.

{
  "$schema": "https://power-automate-tools.local/flow-editor.json#",
  "connectionReferences": {
    "shared_office365": {
      "connectionName": "shared-office365-00000000-1111-2222-3333-444444444444",
      "source": "Embedded",
      "id": "/providers/Microsoft.PowerApps/apis/shared_office365",
      "displayName": "Office 365 Outlook",
      "tier": "Standard",
      "apiName": "office365"
    }
  },
  "definition": {
    "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
      "$connections": {
        "defaultValue": {},
        "type": "Object"
      },
      "$authentication": {
        "defaultValue": {},
        "type": "SecureObject"
      }
    },
    "triggers": {
      "manual": {
        "type": "Request",
        "kind": "Http",
        "inputs": {
          "method": "POST",
          "triggerAuthenticationType": "All"
        }
      }
    },
    "actions": {
      "Create_HTML_table": {
        "runAfter": {},
        "type": "Table",
        "inputs": {
          "from": "@triggerBody()?['credentials']",
          "format": "HTML"
        }
      },
      "Send_an_email_(V2)": {
        "runAfter": {
          "Create_HTML_table": [
            "Succeeded"
          ]
        },
        "type": "OpenApiConnection",
        "inputs": {
          "host": {
            "apiId": "/providers/Microsoft.PowerApps/apis/shared_office365",
            "connectionName": "shared_office365",
            "operationId": "SendEmailV2"
          },
          "parameters": {
            "emailMessage/To": "iam-alerts@contoso.com",
            "emailMessage/Subject": "[AppReg Alert] @{triggerBody()?['totalFlagged']} credentials expiring - Tenant: @{triggerBody()?['tenant']}",
            "emailMessage/Body": "<p>Hello,</p><p>This is your daily <b>App Registration Secret/Certificate Expiry Alert</b>.</p><h3>Summary</h3><table border=\"1\" style=\"border-collapse:collapse;font-family:Arial,sans-serif;\"><tbody><tr><th style=\"padding:6px;background:#f0f0f0;\">Metric</th><th style=\"padding:6px;background:#f0f0f0;\">Count</th></tr><tr><td style=\"padding:6px;\">Total Flagged (within 60 days)</td><td style=\"padding:6px;\"><b>@{triggerBody()?['totalFlagged']}</b></td></tr><tr><td style=\"padding:6px;color:red;\">Already Expired</td><td style=\"padding:6px;color:red;\"><b>@{triggerBody()?['alreadyExpired']}</b></td></tr><tr><td style=\"padding:6px;color:red;\">Expiring within 7 days</td><td style=\"padding:6px;color:red;\"><b>@{triggerBody()?['expiringIn7d']}</b></td></tr><tr><td style=\"padding:6px;color:orange;\">Expiring in 8-30 days</td><td style=\"padding:6px;color:orange;\"><b>@{triggerBody()?['expiringIn30d']}</b></td></tr><tr><td style=\"padding:6px;color:#DAA520;\">Expiring in 31-60 days</td><td style=\"padding:6px;color:#DAA520;\"><b>@{triggerBody()?['expiringIn60d']}</b></td></tr></tbody></table><br><p><b>Tenant:</b> @{triggerBody()?['tenant']}<br><b>Report generated:</b> @{triggerBody()?['timestamp']}</p><p>Please review and renew credentials before they expire.</p><h3>Credentials Expiring Within 60 Days</h3>@{body('Create_HTML_table')}",
            "emailMessage/Importance": "Normal"
          },
          "authentication": "@parameters('$authentication')"
        }
      }
    },
    "outputs": {}
  }
}


Results

The monitor now runs on a schedule, and every morning the team gets a single email that answers everything at a glance:

ObjectTypeAppNameCredTypeExpiryDateDaysLeftOwners
AppRegHR-Sync-IntegrationSecret2026-08-1914alex.doe@contoso.com
EntAppFinance-API-GatewayCertificate2026-09-0228pat.roe@contoso.com
AppRegLegacy-Batch-LoaderSecret2026-09-2854No owner assigned

No more 2 AM surprises. No more "who owns this app?" scavenger hunts. And the "No owner assigned" rows have turned into their own cleanup project — arguably the most valuable side effect of the whole exercise.



Featured Post

How to Set Up a Vanity Email Address in Microsoft 365 (Exchange Online)

How to Set Up a Vanity Email Address in Microsoft 365 (Exchange Online) A vanity email address is a friendly, memorable address — like hello...

Popular posts