Sending Email from an AKS Pod Without a Single Secret
If you’re running a workload in AKS that occasionally needs to send out emails — whether it’s a notification, an alert, or a report — you might be wondering about the best way to do it. There are a few common methods, but they often come with their share of drawbacks:
- Using an SMTP relay introduces yet another credential that you’ll have to manage, rotate, and risk exposure.
- Setting up a shared mailbox with an “app password” creates an identity that isn’t owned by anyone, allowing it to authenticate indefinitely.
- While it might be tempting to grant Mail.Send permission through Microsoft Graph, it actually permits your app to send emails as any mailbox within the entire tenant, which isn’t what most people intend.
In this article, we’ll explore a solution that bypasses these issues: utilizing Azure Workload Identity for the pod, Microsoft Graph for sending the emails, and Exchange Online RBAC for Applications to limit the identity to a specific authorized mailbox. This way, there are no secrets stored in your cluster, and no blanket permissions granted across the tenant.
Pod (AKS) — federated OIDC token –> Workload Identity
Workload Identity — client_credentials + client_assertion –> Microsoft Entra ID
Entra ID — access token (aud=Graph) –> Pod
Pod — POST /v1.0/users/{sender}/sendMail (Bearer token)–> Microsoft Graph –> Exchange Online
The pod operates without directly managing passwords or client secrets. Instead, it automatically reads a federated identity token from Azure Workload Identity’s webhook, exchanges it for a Graph access token via Entra ID, and then invokes the sendMail function. The only factor determining which mailbox it can send from lies in the role assignment on the Exchange Online side — nothing is set in Kubernetes.
Remember, this is a request for your platform team, not something you can set up by yourself within the cluster. You’ll need to acquire a Managed Identity (or App Registration) with a Federated Identity Credential specifically issued for your Kubernetes namespace and ServiceAccount subject. When reaching out to your platform or identity team, provide them with:
- The target namespace (e.g. my-namespace)
- The name of the target ServiceAccount (e.g. my-mail-service-account)
- The use case you have in mind (application emailing through Microsoft Graph)
They’ll return a Client ID that your manifest will need.
One vital thing not to ask for: avoid requesting the Graph Mail.Send application permission for this identity. If granted directly in Entra ID, this permission would be unscoped — allowing the identity to send emails as anyone. The real authorization for sending will come from Exchange, not Entra ID (see Step 2).
Currently, Microsoft recommends scoping application email-sending rights using RBAC for Applications in Exchange Online. This is a modern alternative to the previously deprecated Application Access Policies. Here’s a brief overview:
- The Managed Identity should receive no unscoped Graph Mail.Send permission in Entra ID. (Entra ID permissions and Exchange RBAC permissions are additive — leaving an unscoped permission in place negates the benefit.)
- Exchange Online makes a reference to the Managed Identity’s service principal (New-ServicePrincipal), and assigns it the Application Mail.Send role, limited to a Management Scope that points to just one mailbox (or one security group of mailboxes).
- With this setup, the identity can call /users/{authorized-mailbox}/sendMail and should receive a 202 response. Any attempts to use a different mailbox will return a 403 error.
This step generally falls under the responsibility of whoever manages your Exchange Online tenant, not the application team. It’s simply a matter of running a brief PowerShell script (New-ManagementScope, New-ServicePrincipal, New-ManagementRoleAssignment), all of which is well-documented in Microsoft’s RBAC for Applications documentation. For reference, here’s that runbook:
# 0. Ensure no unscoped Graph Mail.Send permission is assigned to the identity first
$mi = Get-EntraServicePrincipal -ServicePrincipalId $MiObjectId
$graphSp = Get-EntraServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$mailSendRole = $graphSp.AppRoles | Where-Object {
$_.Value -eq "Mail.Send" -and $_.AllowedMemberTypes -contains "Application"
}
Get-EntraServicePrincipalAppRoleAssignment -ServicePrincipalId $mi.Id |
Where-Object { $_.ResourceId -eq $graphSp.Id -and $_.AppRoleId -eq $mailSendRole.Id } |
ForEach-Object { Remove-EntraServicePrincipalAppRoleAssignment -ServicePrincipalId $mi.Id -AppRoleAssignmentId $_.Id }
# 1. Connect to Exchange Online
Connect-ExchangeOnline
# 2. Scope to a specific mailbox (use ExternalDirectoryObjectId, not SMTP address)
$mbx = Get-EXORecipient -Identity $AllowedMailbox -Properties ExternalDirectoryObjectId
New-ManagementScope -Name $ScopeName `
-RecipientRestrictionFilter "ExternalDirectoryObjectId -eq '$($mbx.ExternalDirectoryObjectId)'"
# 3. Register the service principal pointer (this creates a reference, not a new identity)
$exoSp = New-ServicePrincipal -AppId $MiAppId -ObjectId $MiObjectId -DisplayName $MiDisplayName
# 4. Assign the Application Mail.Send role, scoping it accordingly
New-ManagementRoleAssignment -Name $AssignmentName `
-App $exoSp.ObjectId -Role "Application Mail.Send" -CustomResourceScope $ScopeName
# 5. Validate both directions
Test-ServicePrincipalAuthorization -Identity $exoSp.ObjectId -Resource $AllowedMailbox # expect InScope = True
Test-ServicePrincipalAuthorization -Identity $exoSp.ObjectId -Resource $BlockedMailbox # expect InScope = False
Here’s something to keep in mind: when using -App in steps 3/4, it requires the Object ID of the Enterprise application/service principal, not the App Registration object. Also, always avoid using Application Mail Full Access or an exclusive management scope, since Microsoft states that exclusive scopes do not restrict application access.
It’s important to note: this RBAC scope only limits the sender. It does not have any concept of a recipient allowlist. Once your identity is set up to send as a mailbox, it can send emails to anyone, whether they’re inside or outside your tenant. If you need to restrict recipients, that will need to be managed through your application logic, as Exchange RBAC won’t handle it for you.
You’ll need three resources: a ServiceAccount annotated with the Client ID from Step 1, a ConfigMap containing the email sending script, and a Deployment to mount it. Here’s how the complete manifest looks:
---
apiVersion: v1
kind: ServiceAccount
metadata:
annotations:
azure.workload.identity/client-id:
name: my-mail-service-account
namespace: my-namespace
---
apiVersion: v1
kind: ConfigMap
metadata:
name: send-email
namespace: my-namespace
data:
send-email.sh: |
#!/bin/bash
set -euo pipefail
if [ $# -lt 2 ]; then
echo "Usage: $0 [recipient-email...]"
exit 1
fi
SENDER_EMAIL="$1"
shift
RECIPIENTS=("$@")
TENANT_ID="${AZURE_TENANT_ID:-}"
CLIENT_ID="${AZURE_CLIENT_ID:-}"
FEDERATED_TOKEN_FILE="${AZURE_FEDERATED_TOKEN_FILE:-}"
if [ -z "$TENANT_ID" ] || [ -z "$CLIENT_ID" ] || [ -z "$FEDERATED_TOKEN_FILE" ]; then
echo "ERROR: Workload Identity variables not set"
exit 1
fi
FEDERATED_TOKEN=$(cat "$FEDERATED_TOKEN_FILE")
TOKEN_RESPONSE=$(curl -s -X POST \
"https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=${CLIENT_ID}" \
-d "scope=https://graph.microsoft.com/.default" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion=${FEDERATED_TOKEN}" \
-d "grant_type=client_credentials")
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
if [ -z "$ACCESS_TOKEN" ]; then
echo "ERROR: Failed to acquire token"
echo "$TOKEN_RESPONSE"
exit 1
fi
RECIPIENTS_JSON=""
for r in "${RECIPIENTS[@]}"; do
if [ -n "$RECIPIENTS_JSON" ]; then
RECIPIENTS_JSON="${RECIPIENTS_JSON},"
fi
RECIPIENTS_JSON="${RECIPIENTS_JSON}{\"emailAddress\":{\"address\":\"${r}\"}}"
done
cat > /tmp/email.json </az-init:0.1
command: ["sleep", "3600"]
readinessProbe:
exec:
command: ["ls"]
livenessProbe:
exec:
command: ["ls"]
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
volumeMounts:
- name: script
mountPath: "/script"
volumes:
- name: script
configMap:
name: send-email
defaultMode: 0555
A couple of important things to watch out for:
- /me/sendMail won’t work here. When using a
client_credentialsflow (app-only, no signed-in user),/megenerates a 400 Bad Request error: /me requests are only valid with a delegated authentication flow. You need to use/users/{sender}/sendMailand explicitly specify the sender. - Don’t overlook configMap.defaultMode. Using 0500 grants execution only to the file’s owner (root); if your container runs as a non-root user, you’ll encounter a
Permission deniederror during execution. Set it to 0555 instead.
kubectl apply -f email-pod.yaml
kubectl exec -n my-namespace -it deploy/debug-deployment -- \
/script/send-email.sh [email protected] \
[email protected] [email protected]
If you receive a 202 response along with the message Email sent successfully, it means everything worked. Try using a sender mailbox that is not included in your RBAC scope, and you should get a 403 response. If not, double-check to ensure that no unscoped Graph Mail.Send permission is still on the identity in Entra ID — that’s a common pitfall that may lead to unexpected outcomes.
This approach allows you to meet the application’s email-sending requirements without embedding static credentials into your cluster or expanding permissions beyond what’s absolutely necessary.
Fully cloud-based, with no reliance on on-premises systems or manually managed secrets:
- There is no password, API key, or certificate stored in Kubernetes (Secrets, ConfigMaps, or any other location). Authentication is entirely based on OIDC federation between your Kubernetes ServiceAccount and Microsoft Entra ID (Workload Identity) — the federated token is automatically issued and mounted by the webhook, eliminating the need for manual generation or distribution.
- The entire chain of authentication and sending (Entra ID, Microsoft Graph, Exchange Online) is routed through Microsoft-managed HTTPS endpoints, with no on-prem component included in the application flow. (Section A.3 discusses a common pitfall related to Exchange hybrid scenarios but does not affect the authentication process.)
- The processes of provisioning the sender mailbox and managing RBAC configuration are handled natively within Microsoft 365 / Exchange Online, with no extra scripts or infrastructure necessary on the client-side.
Designed with security in mind (least privilege):
- The OIDC federated token has a short lifespan (limited duration, auto-renewed by the webhook) and is scoped specifically to the my-mail-service-account ServiceAccount in the my-namespace namespace — it can’t be reused outside this particular context.
- No tenant-wide Microsoft Graph Mail.Send permission is granted to the identity; without this measure, the identity could send emails as any mailbox within the tenant. Real authorization is handled through Exchange Online RBAC for Applications, strictly limited to the mailboxes that belong to a defined Exchange group.
- Containment is verified in both directions before moving to production – not solely relying on the cases where it’s expected to work.
- A known limitation: this RBAC model restricts only the sender and does not manage recipient restrictions — you’ll need to implement these controls within your application logic.
See You in the Cloud
Jamesdld
Share this content:
Discover more from Qureshi
Subscribe to get the latest posts sent to your email.