Skip to content

Authentication

There are two independent trust boundaries - don’t conflate them:

Two trust boundaries: the caller presents a bearer JWT to exonaut, which checks the route's required role against 30 narrow roles; exonaut then signs in to Exchange Online app-only, using Managed Identity, a certificate, a client secret or an interactive account.Two trust boundaries: the caller presents a bearer JWT to exonaut, which checks the route's required role against 30 narrow roles; exonaut then signs in to Exchange Online app-only, using Managed Identity, a certificate, a client secret or an interactive account.
  1. Inbound - callers present an Entra-issued JWT. Each route demands a specific narrow permission. Missing token → 401; token without the right role → 403.
  2. Outbound - the API authenticates itself to EXO via the mode you configure.

Outbound connection modes (ExchangeOnline:ConnectionMode)

Section titled “Outbound connection modes (ExchangeOnline:ConnectionMode)”
Mode Required config Best for
None - Offline metadata/Explorer (default)
SystemAssignedManagedIdentity Organization Production on Azure App Service
UserAssignedManagedIdentity Organization, ManagedIdentityClientId Production with a shared MI
Certificate Organization, AppId, CertificateThumbprint Production fallback + Purview
ClientSecret Organization, AppId, TenantId, ClientSecret Local dev - unattended
Interactive Organization, InteractiveUserPrincipalName Local dev with a personal admin

In ClientSecret mode the secret is exchanged for an access token outside the PowerShell pipeline (Connect-ExchangeOnline -AccessToken), so it never appears in cmdlet logs.

Expose the same app registration as a protected API (Application ID URI api://<app-id>), define the delegated scopes and application roles (Entra requires app-role values to carry a .All suffix), add the AzureAd:* settings, then:

Terminal window
TOKEN=$(curl -s -X POST "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
-d "client_id=$APP_ID&client_secret=$CLIENT_SECRET&scope=api://$APP_ID/.default&grant_type=client_credentials" \
| jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" "http://localhost:5043/exchange/v1.0/mailboxes?\$top=2"

Access is split into 30 narrow roles rather than one master key, roughly 1:1 with consumer personas. A help-desk read tool gets Mailbox.Read.All and nothing else; a group bot gets Group.Read.All + Group.ReadWrite.All. A write action always carries a .ReadWrite policy. Where the controller also carries a class-level .Read policy the caller needs both roles, that’s 147 of 331 write actions (about 44%); the rest, 184 of 331 (about 56%), including Mailboxes, Users, and Recipients, gate the write on .ReadWrite alone, with no class-level .Read requirement. Either way, a read-only token can never write. Representative slice:

Route family Role required
/users User.Read[.All] / User.ReadWrite[.All]
/mailboxes (+ per-mailbox config & jobs) Mailbox.Read[.All] / Mailbox.ReadWrite[.All]
/mailboxes/{id}/permissions, /recipientPermissions, /folderPermissions MailboxPermission.*
/distributionGroups, /unifiedGroups, /dynamicDistributionGroups Group.Read[.All] / Group.ReadWrite[.All]
/transportRules, connectors, domains, address books MailFlow.*
RBAC family Rbac.*
Defender / threat policies + quarantine ThreatPolicy.*
retention · compliance · mailbox searches Compliance.* · Compliance.IPPS.*
message trace · reports Reports.*

The exact per-route role is published in /explorer/inventory.json (and in the API reference). The Explorer reads server-emitted auth config and uses MSAL.js delegated sign-in - no manual token plumbing for interactive use.