DEV Community

Cover image for Granting OAuth2 Application Access for Microsoft Graph
B Mithilesh
B Mithilesh

Posted on

Granting OAuth2 Application Access for Microsoft Graph

Granting OAuth2 Application Access for Microsoft Graph Mail.Send

DEMO CODE LINK IN GIST

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

  1. Go to Entra ID → App registrations → New registration
  2. Give it a name (e.g. mail-sender-service)
  3. Leave redirect URI blank (not needed for client credentials flow)
  4. Register, then copy:
    • Tenant ID
    • Client ID (Application ID)

You'll need both for token requests.


Step 2 — Create a Client Secret

  1. Certificates & secrets → New client secret
  2. Set an expiry (90 days, 6 months, 1 year — your call, but shorter is safer)
  3. 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 .env committed to git.


Step 3 — Add the Microsoft Graph Application Permission

  1. API permissions → Add a permission
  2. Choose Microsoft Graph
  3. Choose Application permissions (not Delegated)
  4. Search for and select Mail.Send
  5. Click Add permissions

Step 4 — Grant Admin Consent

Application permissions don't activate on their own — an admin has to explicitly approve them.

  1. Click Grant admin consent for <tenant>
  2. Confirm. The permissions table should now read:
Microsoft Graph
Mail.Send (Application)
Status: Granted ✅
Enter fullscreen mode Exit fullscreen mode

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 ErrorMailboxNotEnabledForRESTAPI or 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

⚠️ Note the scope: https://graph.microsoft.com/.default. With client credentials, you don't request individual scopes — .default pulls 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
Enter fullscreen mode Exit fullscreen mode

Request body:

{
  "message": {
    "subject": "Build Notification",
    "body": {
      "contentType": "Text",
      "content": "The pipeline finished successfully."
    },
    "toRecipients": [
      { "emailAddress": { "address": "you@domain.com" } }
    ]
  },
  "saveToSentItems": "false"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 8 — Confirm Success

A response of:

HTTP 202 Accepted
Enter fullscreen mode Exit fullscreen mode

means Graph has accepted the message for delivery. No response body is returned on success202 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.Send as 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.Send added as Application permission
  • [ ] Admin consent granted
  • [ ] Sender mailbox confirmed to exist and be licensed
  • [ ] Token acquired via client credentials flow
  • [ ] sendMail POST returns 202 Accepted

Once all eight boxes are checked, your service can send email through Graph with zero user interaction — clean, auditable, and fully app-owned.

Github gist also embeded here if scraping :)

Top comments (0)