Authenticate from Azure with managed identity
Azure workloads can call the Groundmail API with their managed identity: no API key to store, rotate or leak. Your app asks Azure for a short-lived Microsoft Entra access token and sends it as the Bearer credential. Groundmail checks the token's signature against Microsoft's published keys and matches it to an identity you registered in the dashboard.
It works anywhere Azure gives a workload an identity: Container Apps, App Service, Functions, AKS (workload identity), Virtual Machines and VM scale sets, and pipelines that sign in as a service principal.
How it works
- You add the Groundmail app to your Microsoft Entra tenant, once. Its App ID URI,
https://api.groundmail.dev, is the token audience: a token for it is meant for Groundmail and nothing else. - You register your workload's managed identity in Groundmail: its directory (tenant) ID, its object (principal) ID, and what it may do.
- Your code gets a token for
https://api.groundmail.devfrom the managed identity and sendsAuthorization: Bearer <token>.
On every request Groundmail checks that:
- the token is signed (RS256) with a key Microsoft publishes for your tenant;
- it was issued by Microsoft Entra ID for your tenant (
https://sts.windows.net/{tenant}/orhttps://login.microsoftonline.com/{tenant}/v2.0); - it is within its validity window (
nbf/exp, with two minutes of clock skew); - its audience is
https://api.groundmail.dev(or your own App ID URI, if you use your own app registration); - its tenant ID and object ID match a registered, active identity;
- it is an app-only token. Tokens issued to a user (delegated tokens with an
scpclaim) are refused.
Adding the Groundmail app gives Groundmail no access to your tenant: it asks for no permissions and has no access to your data. It only lets Entra issue tokens whose audience is Groundmail. Groundmail still accepts only the identities you register.
Tokens never leave your request and are never stored or logged.
Step 1: add Groundmail to your tenant
Do this once per Entra tenant. It creates the Groundmail enterprise application (service principal) in your tenant, which Entra needs before it will issue tokens for https://api.groundmail.dev.
Azure CLI (or Azure Cloud Shell in the portal):
az ad sp create --id 0146ad5c-4051-4703-aece-e4edf9805d8ePowerShell (Microsoft Graph module):
Connect-MgGraph -Scopes "Application.ReadWrite.All"
New-MgServicePrincipal -AppId 0146ad5c-4051-4703-aece-e4edf9805d8e0146ad5c-4051-4703-aece-e4edf9805d8e is the Groundmail app's application (client) ID. Afterwards it appears under Microsoft Entra ID → Enterprise applications as Groundmail.
Adding an enterprise application usually needs an admin role such as Application Administrator or Cloud Application Administrator, unless your tenant's policy lets users add apps. If you don't have the role, ask a tenant admin to run the command above; it takes a few seconds and doesn't need to be repeated.
Optional hardening: to control which identities can get tokens for Groundmail at all, open the Groundmail enterprise application, set Assignment required to Yes under Properties, and assign your managed identities to it with Microsoft Graph (New-MgServicePrincipalAppRoleAssignment, using the default access role ID 00000000-0000-0000-0000-000000000000). Groundmail already accepts only the identities you register; this adds a second check on the Azure side.
If your tenant can't add third-party apps, use your own app registration instead.
Step 2: turn on the workload's managed identity
A system-assigned identity is the simplest. For Container Apps:
az containerapp identity assign -n my-app -g my-rg --system-assigned
az containerapp show -n my-app -g my-rg --query identity.principalId -o tsv # Object (principal) ID
az account show --query tenantId -o tsv # Directory (tenant) IDThe same pattern works for App Service and Functions (az webapp identity assign, az functionapp identity assign) and VMs (az vm identity assign). In the portal it's the Identity blade of the resource: switch System assigned on and copy the Object (principal) ID.
For a user-assigned identity, use its Object (principal) ID (az identity show -n my-identity -g my-rg --query principalId -o tsv), and tell your code which identity to use with its client ID (AZURE_CLIENT_ID, or ManagedIdentityClientId in .NET).
Step 3: register the identity in Groundmail
In the dashboard, open API keys → Azure managed identity → Add Azure identity and enter:
| Field | Value |
|---|---|
| Name | Something you'll recognize, like "Orders service (prod)" |
| Directory (tenant) ID | Your Entra tenant ID |
| Object (principal) ID | The managed identity's object ID, not its client ID |
| Client ID (optional) | A user-assigned identity's client ID. When set, the token's appid / azp must match too |
| Audience | Leave empty. It defaults to https://api.groundmail.dev. Only fill it in when you use your own app registration |
| Access | Send only (recommended), Full or Read only, plus an optional domain limit and tenant, exactly like an API key |
The dashboard then shows ready-to-copy code for the identity. Each identity appears with its last use, and revoking it stops it working from the next request.
Step 4: call the API
Request a token for https://api.groundmail.dev/.default and send it as the Bearer credential. Tokens last 60 to 90 minutes; the Azure SDKs cache and refresh them, and so do the Groundmail SDKs below.
C# (.NET)
// dotnet add package Azure.Identity
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Azure.Core;
using Azure.Identity;
var credential = new DefaultAzureCredential();
var scope = new TokenRequestContext(new[] { "https://api.groundmail.dev/.default" });
var http = new HttpClient { BaseAddress = new Uri("https://api.groundmail.dev") };
async Task<HttpResponseMessage> SendAsync(object email)
{
// DefaultAzureCredential caches the token and refreshes it before it expires.
AccessToken token = await credential.GetTokenAsync(scope);
using var request = new HttpRequestMessage(HttpMethod.Post, "/emails") { Content = JsonContent.Create(email) };
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await http.SendAsync(request);
}
var response = await SendAsync(new
{
from = "Acme <no-reply@acme.com>",
to = "ada@example.com",
subject = "Reset your password",
html = "<p>Your reset link: <a href=\"https://acme.com/reset/abc123\">reset</a></p>",
});
response.EnsureSuccessStatusCode();For a user-assigned identity: new DefaultAzureCredential(new DefaultAzureCredentialOptions { ManagedIdentityClientId = "<client-id>" }).
Node.js
// npm install groundmail @azure/identity
import { DefaultAzureCredential } from '@azure/identity'
import { Groundmail } from 'groundmail'
const credential = new DefaultAzureCredential()
const groundmail = new Groundmail({
// Called before requests; the SDK caches the token until shortly before it
// expires, and asks for a fresh one once after a 401.
getToken: () => credential.getToken('https://api.groundmail.dev/.default'),
})
await groundmail.emails.send({
from: 'Acme <no-reply@acme.com>',
to: 'ada@example.com',
subject: 'Reset your password',
html: '<p>Your reset link: <a href="https://acme.com/reset/abc123">reset</a></p>',
})getToken can return the token string or an object with token and expiresOnTimestamp (what credential.getToken() returns). The groundmail package doesn't depend on @azure/identity; you pass the function. Needs groundmail 0.25.0 or later.
Python
# pip install groundmail azure-identity
from azure.identity import DefaultAzureCredential
from groundmail import Groundmail
credential = DefaultAzureCredential()
groundmail = Groundmail(
# Called before requests; cached until shortly before it expires.
token_provider=lambda: credential.get_token("https://api.groundmail.dev/.default"),
)
groundmail.emails.send(
from_="Acme <no-reply@acme.com>",
to="ada@example.com",
subject="Reset your password",
html='<p>Your reset link: <a href="https://acme.com/reset/abc123">reset</a></p>',
)token_provider can return a string or azure-identity's AccessToken. Needs groundmail 0.9.0 or later.
curl, from inside the workload
# Container Apps, App Service and Functions set IDENTITY_ENDPOINT and IDENTITY_HEADER.
TOKEN=$(curl -s "$IDENTITY_ENDPOINT?api-version=2019-08-01&resource=https://api.groundmail.dev" \
-H "X-IDENTITY-HEADER: $IDENTITY_HEADER" | jq -r .access_token)
# On a VM, ask the instance metadata service instead:
# curl -s -H "Metadata: true" "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://api.groundmail.dev"
curl -X POST https://api.groundmail.dev/emails \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"from":"no-reply@acme.com","to":"ada@example.com","subject":"Hello","html":"<p>No API key.</p>"}'From a VM or a pipeline signed in with its identity, the Azure CLI works too:
az login --identity
TOKEN=$(az account get-access-token --resource https://api.groundmail.dev --query accessToken -o tsv)Other languages work the same way: any HTTP client, with the token from your Azure identity library in the Authorization header.
Alternative: your own app registration
If your tenant can't add third-party apps, create an app registration that stands for Groundmail in your own tenant and use its App ID URI as the audience instead of https://api.groundmail.dev. It needs no secret, certificate, redirect URI or API permissions.
Azure CLI:
APP_ID=$(az ad app create --display-name "Groundmail API" --query appId -o tsv)
az ad app update --id "$APP_ID" --identifier-uris "api://$APP_ID"
az ad sp create --id "$APP_ID"
echo "Audience (App ID URI): api://$APP_ID"
echo "Audience application ID: $APP_ID"Azure portal: Microsoft Entra ID → App registrations → New registration (name it "Groundmail API", single tenant, no redirect URI) → Expose an API → Add next to Application ID URI and accept api://<application-id>. Creating it in the portal also creates its enterprise application (service principal).
Then:
- In the dashboard, open Use your own app registration when you add the identity, and enter the App ID URI (for example
api://6d1f2c3a-…) as the Audience. If you set the registration to issue v2.0 tokens, also enter its application (client) ID as Audience application ID. - In your code, request tokens for
api://<application-id>/.defaultinstead ofhttps://api.groundmail.dev/.default(orresource=api://<application-id>for the managed identity endpoint).
Use a dedicated app registration. Don't reuse the App ID URI of one of your own APIs: any service that receives tokens for an audience could replay them to every other service that accepts that audience.
Permissions
A registered identity has the same options as an API key:
- Access: Full, Send only, or Read only. Send only is the right choice for most app workloads.
- Domain limit: only send from specific verified domains.
- Tenant: limit the identity to one of your tenants, so it sends only as that customer.
Sends made with an identity show up under its name in Emails, count toward your plan like any other send, and follow the same rate limits. Creating and revoking identities is recorded in your workspace's audit log, and the workspace owner gets the same security email as for a new API key.
Local development
DefaultAzureCredential falls back to your developer sign-in (Azure CLI, Visual Studio) when there's no managed identity. Tokens issued to a person are delegated tokens, which Groundmail refuses on purpose. For local development, either:
- use a test API key (
gm_test_…) locally and the managed identity in Azure, or - sign in as a service principal (
az login --service-principal) and register that service principal's object ID as a separate identity with limited access.
Troubleshooting 401s
Every authentication failure returns the same response, so a caller can't probe which check failed:
{ "error": { "code": "unauthorized", "message": "Invalid API key or access token" } }When the token was really issued by Microsoft for a registered identity, the dashboard shows the reason under Last used ("Last rejected token: …"). Check, in order:
| Symptom | Fix |
|---|---|
| "audience does not match" | Request the token for https://api.groundmail.dev/.default, or, with your own app registration, exactly the App ID URI you registered plus /.default. If your own registration issues v2.0 tokens, also enter its application (client) ID in Audience application ID. |
| No reason shown at all | Groundmail found no active identity with that tenant ID and object ID. Check you registered the identity's object (principal) ID, not its client ID, and that it isn't revoked. Decode the token at a JWT viewer you trust and compare tid and oid. |
| "issued to a user" | The token came from a developer sign-in, not the managed identity. See Local development. |
| "has expired" / "not valid yet" | Don't cache tokens yourself past expiresOn; let the SDK refresh them. Check the machine's clock. |
| "client ID does not match" | You set a client ID on the identity and the token came from another identity. Clear the field or fix AZURE_CLIENT_ID. |
AADSTS500011 when requesting the token |
The Groundmail app hasn't been added to your tenant yet. Run az ad sp create --id 0146ad5c-4051-4703-aece-e4edf9805d8e (step 1). With your own app registration, run az ad sp create --id <application-id>. |
AADSTS501051 / "not assigned to a role" |
You set Assignment required on the enterprise application. Assign the managed identity to it. |
403 insufficient_scope |
The token is fine but the identity's access doesn't cover the endpoint. Register it again with the access it needs. |
403 domain_not_allowed |
The identity is limited to other sending domains. |
Tokens are checked against the global Azure cloud (login.microsoftonline.com). Azure Government and Azure China tokens aren't accepted yet. Contact us if you need them.
Related
- Authentication: API keys, OAuth tokens and scopes
- API keys: access options shared with Azure identities
- Migrate from Azure Communication Services Email