Authentication
There are two independent trust boundaries - don’t conflate them:
- Inbound - callers present an Entra-issued JWT. Each route demands a specific narrow
permission. Missing token →
401; token without the right role →403. - 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.
Inbound caller auth (Phase 3)
Section titled “Inbound caller auth (Phase 3)”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:
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"The least-privilege role model
Section titled “The least-privilege role model”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.

