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 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.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)