Granting OAuth2 Application Access for Microsoft Graph Mail.Send
If you're building a backend service, automation script, or notification system that needs to send email without a logged-in user, Microsoft Graph's Mail.Send application permission is the way to go. This guide walks through the full setup — from app registration to that first 202 Accepted.
Why Application Permissions (not Delegated)?
| Delegated Permissions | Application Permissions | |
|---|---|---|
| Requires user sign-in | 🥸 Yes | 📉 No |
Works with ClientSecretCredential |
🫄🏻 No | 🐒 Yes |
| Use case | Interactive apps, "send as me" | Daemons, backend services, cron jobs |
| Token acquisition flow | Auth code / device code | Client credentials |
If your service runs unattended (a cron job, a backend worker, a CI pipeline), you need application permissions. There's no user context to delegate from.
Step 1 — Create an App Registration
- Go to Entra ID → App registrations → New registration
- Give it a name (e.g.
mail-sender-service) - Leave redirect URI blank (not needed for client credentials flow)
- Register, then copy:
- Tenant ID
- Client ID (Application ID)
You'll need both for token requests.
Step 2 — Create a Client Secret
- Certificates & secrets → New client secret
- Set an expiry (90 days, 6 months, 1 year — your call, but shorter is safer)
- Copy the secret value immediately — it's shown once and never again
💡 Tip: Store this in a secrets manager (Key Vault, AWS Secrets Manager, Doppler, etc.), not in a
.envcommitted to git.
Step 3 — Add the Microsoft Graph Application Permission
- API permissions → Add a permission
- Choose Microsoft Graph
- Choose Application permissions (not Delegated)
- Search for and select
Mail.Send - Click Add permissions
Step 4 — Grant Admin Consent
Application permissions don't activate on their own — an admin has to explicitly approve them.
- Click Grant admin consent for
<tenant> - Confirm. The permissions table should now read:
Microsoft Graph
Mail.Send (Application)
Status: Granted ✅
If it still says "Not granted," the token request will succeed but sendMail calls will fail with a 403.
Step 5 — Verify the Sender Mailbox Exists
This step trips people up. Mail.Send (application) lets your app send as any mailbox in the tenant — but only if that mailbox actually exists and is licensed.
- The sender (e.g.
noreply.ai@domain.com) must be a real, licensed Exchange Online mailbox - A user without an Exchange Online license will fail with
ErrorMailboxNotEnabledForRESTAPIor similar - Shared mailboxes work fine and don't need their own license, as long as Exchange Online is provisioned for the tenant
Step 6 — Get an OAuth2 Access Token (Client Credentials Flow)
With Tenant ID, Client ID, and Client Secret in hand, request a token:
curl -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_secret={client-secret}" \
-d "grant_type=client_credentials"
Python (using azure-identity):
from azure.identity import ClientSecretCredential
credential = ClientSecretCredential(
tenant_id="YOUR_TENANT_ID",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
)
token = credential.get_token("https://graph.microsoft.com/.default")
access_token = token.token
⚠️ Note the scope:
https://graph.microsoft.com/.default. With client credentials, you don't request individual scopes —.defaultpulls in whatever's been admin-consented for the app.
Step 7 — Send the Email
POST https://graph.microsoft.com/v1.0/users/{sender-email}/sendMail
Authorization: Bearer <access_token>
Content-Type: application/json
Request body:
{
"message": {
"subject": "Build Notification",
"body": {
"contentType": "Text",
"content": "The pipeline finished successfully."
},
"toRecipients": [
{ "emailAddress": { "address": "you@domain.com" } }
]
},
"saveToSentItems": "false"
}
Python example (requests):
import requests
url = f"https://graph.microsoft.com/v1.0/users/{sender_email}/sendMail"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {
"message": {
"subject": "Build Notification",
"body": {"contentType": "Text", "content": "Pipeline finished successfully."},
"toRecipients": [{"emailAddress": {"address": "you@domain.com"}}],
},
"saveToSentItems": "false",
}
response = requests.post(url, headers=headers, json=payload)
print(response.status_code) # 202 = success
Step 8 — Confirm Success
A response of:
HTTP 202 Accepted
means Graph has accepted the message for delivery. No response body is returned on success — 202 with an empty body is the expected, correct outcome. Don't wait around for a JSON payload that isn't coming.
⚠️ Common Gotcha: ClientSecretCredential Only Works With Application Permissions
If you're using ClientSecretCredential (or any client-credentials-based flow), delegated permissions will not work — there's no signed-in user to delegate from. You must:
- Configure
Mail.Sendas an Application permission (not Delegated) - Have an admin grant consent at the tenant level
- Authenticate purely via client ID + secret (or certificate) — no user interaction, ever
Mixing this up is the #1 cause of 403 Forbidden / Authorization_RequestDenied errors in this flow.
Quick Troubleshooting Reference
| Error | Likely Cause |
|---|---|
403 Authorization_RequestDenied |
Admin consent not granted, or permission is Delegated instead of Application |
ErrorMailboxNotEnabledForRESTAPI |
Sender mailbox isn't licensed for Exchange Online |
400 invalid_client |
Wrong Client ID/Secret, or secret expired |
404 ErrorInvalidUser |
Sender email doesn't exist as a mailbox in the tenant |
Summary Checklist
- [ ] App registered in Entra ID, Tenant ID + Client ID copied
- [ ] Client secret created and stored securely
- [ ]
Mail.Sendadded as Application permission - [ ] Admin consent granted
- [ ] Sender mailbox confirmed to exist and be licensed
- [ ] Token acquired via client credentials flow
- [ ]
sendMailPOST returns202 Accepted
Once all eight boxes are checked, your service can send email through Graph with zero user interaction — clean, auditable, and fully app-owned.
Top comments (0)