Microsoft Graph OAuth: Authenticate Outlook and Microsoft 365 Mailboxes
A complete 2026 guide to Microsoft Graph OAuth for SaaS developers. Covers Microsoft Entra app registration, authority endpoints, Mail scopes, delegated vs application permissions, admin consent, auth code + PKCE, refresh token rotation, AADSTS error codes, and how Unipile eliminates 5 weeks of OAuth plumbing in 5 minutes.
import requests
# Step 1: Generate a hosted auth link
response = requests.post(
"https://apiXXX.unipile.com:XXX
/api/v1/hosted/accounts/link",
headers={"X-API-KEY": api_key},
json={
"type": "create",
"providers": ["MICROSOFT"],
"api_url": your_dsn,
"expiresOn": "2026-12-31T23:59:59Z"
}
)
# Step 2: Redirect your user to the URL
auth_url = response.json()["url"]
# Unipile handles the full OAuth flow
# incl. Entra, scopes, tokens, refreshWhy Microsoft Graph OAuth is Non-Negotiable in 2026
If your SaaS application reads, sends, or syncs Outlook or Microsoft 365 email, Microsoft Graph OAuth is no longer optional. Three major deprecations have made Basic Auth, legacy protocols, and app passwords obsolete. OAuth 2.0 via the Microsoft Graph API is the only supported path.
The 3 OAuth Flows Microsoft Supports (and Which One SaaS Needs)
Microsoft's identity platform supports several OAuth 2.0 grant types. Choosing the wrong one is a common source of wasted engineering time. Here is the breakdown for SaaS applications that access email on behalf of their users.
Microsoft Entra App Registration: 7 Steps
Before your app can request OAuth tokens, you need a registered application in Microsoft Entra ID (formerly Azure Active Directory). Here are the exact steps, including which field values matter and which are cosmetic.
portal.azure.com, go to Microsoft Entra ID (search in the top bar), then App registrations, then click + New registration. See the full Microsoft OAuth documentation for additional context.Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant) and personal Microsoft accounts. This corresponds to the /common authority endpoint./organizations authority and reduces your consent surface area. More on authority endpoints in section 4.Web for server-side apps. Use Single-page application (SPA) for client-side flows (auto-enables PKCE).https://app.yourproduct.com/auth/microsoft/callback. Exact match required, any deviation causes AADSTS50011.http://localhost:3000/callback) but production URIs must use HTTPS. Register both separately.Mail.Read, offline_access, openid, profileMail.ReadWrite, Mail.Send, offline_access, openid, profileclient_id parameter in all auth requests./common instead, but keep this value for admin consent URLs.Choosing the Right Microsoft Authority Endpoint
The authority URL you use in your OAuth requests determines which types of Microsoft accounts can authenticate and what tokens you receive. Getting this wrong causes silent failures where some users cannot authenticate at all.
| Authority URL | Accepts | Use Case | Caveats |
|---|---|---|---|
| /commonMost SaaS | Both Microsoft Entra (work/school) and personal Microsoft accounts (Outlook.com, Hotmail, Live) | Multi-tenant SaaS serving all Microsoft users. One endpoint handles your entire user base. | Tokens are issued by each user's home tenant, not yours. Token validation must use tenant-specific issuer or accept multiple issuers. Cannot enforce Conditional Access policies. |
| /organizations | Microsoft Entra ID accounts only (work/school). No personal Microsoft accounts. | B2B SaaS targeting only enterprise customers, never consumer Outlook.com users. | Users with personal Microsoft accounts will receive an error. Simpler token validation (single issuer pattern acceptable). |
| /consumers | Personal Microsoft accounts only (Outlook.com, Hotmail, Live). | Consumer apps targeting personal inboxes. Rare for B2B SaaS. | Enterprise Microsoft 365 accounts are rejected. Not useful for SaaS serving business users. |
| /{tenant-id} | Accounts in a specific Microsoft Entra tenant only. | Single-tenant internal tooling (your own company's app). Admin consent flows targeting a specific tenant. Also used in the admin consent redirect URL pattern. | Every other tenant's users are rejected. Only appropriate for internal apps or when deliberately locking to one customer's tenant. |
/common endpoint, the iss (issuer) claim in the JWT will be https://login.microsoftonline.com/{tenantId}/v2.0 where {tenantId} varies per user. Configure your JWT validation library to accept any issuer matching https://login.microsoftonline.com/{tenantId}/v2.0 rather than a fixed issuer string.Microsoft Graph Mail Scopes: Granular Breakdown
Microsoft Graph uses permission scopes to control what your application can do. Requesting too many scopes increases friction on the consent screen and reduces conversion. Requesting too few causes runtime errors. Here is every Mail scope you need to know.
| Scope | Type | What it enables | Admin consent? |
|---|---|---|---|
| Mail.Read | Delegated | Read all messages in the authenticated user's mailbox. Includes headers, body, attachments. Read-only - cannot modify or send. | User |
| Mail.ReadBasic | Delegated | Read limited message properties: subject, sender, recipients, date. Cannot read message body or attachments. Useful for lightweight inbox listing without full content access. | User |
| Mail.ReadWrite | Delegated | Read and modify all messages. Includes creating, updating, deleting messages and folders. Superset of Mail.Read - do not request both. | User |
| Mail.Send | Delegated | Send emails as the authenticated user. Required even if you also have Mail.ReadWrite - sending is a separate permission in Microsoft Graph. | User |
| Mail.Read.Shared | Delegated | Read mail in shared mailboxes or other users' mailboxes that the authenticated user has been granted access to. Not for reading the user's own mailbox. | User |
| Mail.ReadWrite.Shared | Delegated | Read and modify mail in shared mailboxes the user has access to. | User |
| Mail.Send.Shared | Delegated | Send email from shared mailboxes or "send on behalf of" another user (if that user has granted access). | User |
| offline_access | Delegated | Instructs Microsoft to issue a refresh token. Without this, you only receive a short-lived access token with no way to renew it. Always required for SaaS applications. | User |
| openid | Delegated | Returns an ID token with basic user identity. Required if you want to know who authenticated without making a separate /me API call. | User |
| profile | Delegated | Adds name and preferred_username claims to the ID token. Usually included with openid. | User |
| Mail.Read (App) | Application | Read all mail in all mailboxes in the tenant without user interaction. Used by daemon services. Requires tenant admin consent. | Admin required |
| Mail.ReadWrite (App) | Application | Read and write all mail in all tenant mailboxes. Very broad permission. Only for trusted internal tooling with explicit tenant admin approval. | Admin required |
scope=Mail.Read%20offline_access%20openid%20profile
scope=Mail.ReadWrite%20Mail.Send%20offline_access%20openid%20profile
Delegated vs Application Permissions: When Each Applies
Microsoft Graph uses two fundamentally different permission models. Most SaaS developers default to the wrong one, which leads to unnecessary admin consent requirements and a broken user experience. Here is exactly when to use each.
Auth Code + PKCE: Step-by-Step Curl Examples
Here is the complete Microsoft Graph OAuth 2.0 Authorization Code flow with PKCE, from generating the code verifier to exchanging tokens. These are production-grade examples you can adapt directly to your stack.
import os, base64, hashlib
# 1. Generate code_verifier (43-128 chars, URL-safe base64)
code_verifier = base64.urlsafe_b64encode(
os.urandom(32)
).decode('utf-8').rstrip('=')
# 2. Generate code_challenge = BASE64URL(SHA256(code_verifier))
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode('utf-8')).digest()
).decode('utf-8').rstrip('=')
# Store code_verifier in session - you need it in step 4
# Send code_challenge in the authorization URLcode - requests an authorization codeoffline_access for refresh tokens.S256 - always use SHA-256, never plain# Build the authorization URL (format for readability)
AUTH_URL="https://login.microsoftonline.com/common/oauth2/v2.0/authorize
?client_id=YOUR_CLIENT_ID
&response_type=code
&redirect_uri=https%3A%2F%2Fapp.com%2Fauth%2Fms%2Fcb
&scope=Mail.ReadWrite%20Mail.Send%20offline_access
&state=RANDOM_STATE_VALUE
&code_challenge=YOUR_CODE_CHALLENGE
&code_challenge_method=S256"
# Redirect the user to $AUTH_URL
# Microsoft handles login, MFA, consent screen
# On success: redirect_uri?code=AUTH_CODE&state=...code query parameter. Validate the state parameter matches what you sent. The code expires in 10 minutes - exchange it immediately in step 4.# Exchange authorization code for tokens
curl -X POST "https://login.microsoftonline.com/common/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE_FROM_CALLBACK" \
-d "redirect_uri=https://app.com/auth/ms/cb" \
-d "code_verifier=YOUR_CODE_VERIFIER" \
-d "scope=Mail.ReadWrite Mail.Send offline_access"{
"token_type": "Bearer",
"scope": "Mail.ReadWrite Mail.Send offline_access",
"expires_in": 3600,
"access_token": "eyJ0eXAiOiJKV1Qi...",
"refresh_token": "0.ARoAi7W...",
"id_token": "eyJ0eXAi..."
}Refresh Token Handling: Rotation, Expiry, and Conditional Access
Microsoft Graph refresh tokens are long-lived but not permanent. Several conditions can invalidate them silently. Understanding these edge cases is what separates a production-grade Microsoft OAuth integration from one that breaks randomly for enterprise users.
invalid_grant errors gracefully and prompt re-auth.invalid_grant error.invalid_grant. This is expected behavior - handle it by marking the linked account as needing re-authorization.# Refresh the access token using the stored refresh token
curl -X POST "https://login.microsoftonline.com/common/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "grant_type=refresh_token" \
-d "refresh_token=STORED_REFRESH_TOKEN" \
-d "scope=Mail.ReadWrite Mail.Send offline_access"
# ALWAYS check for a new refresh_token in the response.
# If present, replace the stored one immediately.
# If you get invalid_grant, prompt the user to re-authenticate.Common AADSTS Errors Decoded
Microsoft Graph OAuth errors follow a consistent AADSTS error code pattern. These are the most common ones you will encounter during development and production, with exact root causes and fixes.
| Error Code | What it means | Root cause and fix |
|---|---|---|
| AADSTS65001 | Consent has not been granted for one or more of the requested scopes | The user has not consented to your app's scopes, or a tenant admin has blocked user consent for your app. Fix: Include prompt=consent in your authorization URL to force a fresh consent screen, or send the admin consent URL to the tenant admin.Add prompt=consent or request admin consent |
| AADSTS50011 | Redirect URI mismatch | The redirect_uri in your request does not exactly match any registered redirect URI in your Entra app registration. Even a trailing slash difference causes this. Fix: Copy the exact URI from your Entra app registration and use it verbatim.Fix: exact URI match in Entra app registration |
| AADSTS700016 | Application not found in tenant | The client_id does not exist in the tenant being authenticated against. Common when using a tenant-specific authority (/{tenant-id}) for a multi-tenant app. Fix: Use /common or /organizations authority for multi-tenant apps.Fix: switch to /common or /organizations authority |
| AADSTS90099 | Application has not been authorized in this tenant (consent_required) | The app exists but has not been consented to in the user's tenant. Differs from AADSTS65001 in that the entire app is blocked, not just specific scopes. Fix: Send the admin consent URL to the customer's IT admin. Fix: admin consent URL to customer tenant admin |
| AADSTS70011 | Provided grant is invalid or expired | The refresh token or authorization code has expired or been revoked. Authorization codes expire in 10 minutes. Refresh tokens expire after 90 days of inactivity or admin revocation. Fix: Prompt user to re-authenticate from the beginning of the OAuth flow. Fix: prompt full re-authentication |
| AADSTS50076 | MFA is required by Conditional Access policy | The user's tenant requires multi-factor authentication for your app. This is a customer-side decision enforced by the tenant admin. Your app cannot bypass it. The user needs to complete MFA. If using the auth code flow, Microsoft will show the MFA prompt automatically in the browser. Issues arise in automated flows (client credentials) that cannot complete MFA. Expected: user must complete MFA |
| AADSTS50020 | User account from external identity provider does not exist in tenant | The user is trying to authenticate with a personal Microsoft account into a tenant that only allows organizational accounts, or vice versa. Fix: Check your authority endpoint - if using /organizations, personal accounts cannot authenticate. Switch to /common if you need both.Fix: switch authority to /common |
| AADSTS53003 | Access blocked by Conditional Access policy | The tenant's Conditional Access policy has blocked this authentication attempt entirely (e.g., blocked country, unmanaged device, blocked app). This is a customer-side decision. You cannot override it. Surface the error to the user and advise them to contact their IT admin. Customer-side: advise user to contact IT admin |
Skip 5 Weeks of OAuth Plumbing with Unipile
Everything in this guide - Entra app registration, authority endpoints, scope selection, PKCE, token rotation, AADSTS error handling - is engineering time that does not move your product forward. Unipile handles the entire Microsoft Graph OAuth stack as a managed service, so your team writes one API call instead of 500 lines of OAuth plumbing.
import requests
UNIPILE_API_URL = "https://apiXXX.unipile.com:XXX"
UNIPILE_API_KEY = "your-api-key"
# Step 1: Generate a hosted auth link for Microsoft
response = requests.post(
f"{UNIPILE_API_URL}/api/v1/hosted/accounts/link",
headers={
"X-API-KEY": UNIPILE_API_KEY,
"Content-Type": "application/json"
},
json={
"type": "create",
"providers": ["MICROSOFT"],
"api_url": UNIPILE_API_URL,
"expiresOn": "2026-12-31T23:59:59Z",
# Optional: tie this link to a specific user
"name": "user_id_123",
# Optional: get notified when account is linked
"notify_url": "https://app.yourproduct.com/webhooks/account-linked"
}
)
# Step 2: Redirect your user to this URL
hosted_auth_url = response.json()["url"]
# Example: https://account.unipile.com/[encoded-token]
# Unipile handles: Entra redirect, consent screen, PKCE,
# token exchange, refresh token storage, scope management
# Step 3: After auth, use Unipile's email API to read/send
emails = requests.get(
f"{UNIPILE_API_URL}/api/v1/emails",
headers={"X-API-KEY": UNIPILE_API_KEY},
params={"account_id": "linked-account-id"}
)Frequently Asked Questions
The most common questions about Microsoft Graph OAuth for email integration, from scope selection to token lifecycle to enterprise consent flows.
/common authority endpoint, a single Microsoft Entra app registration handles authentication for both personal Outlook.com accounts and Microsoft 365 work/school accounts. The key is selecting "Accounts in any organizational directory and personal Microsoft accounts" when registering your app in the Azure portal.invalid_grant error. Handle this gracefully: mark the linked account as requiring re-authentication and surface a clear re-auth prompt in your product. This is expected behavior - a customer-side decision beyond your control.Mail.Read, Mail.ReadWrite, and Mail.Send are user-consent scopes - individual users can approve them during the OAuth flow. Admin consent is only required for Application permissions or high-privilege scopes like User.Read.All. Some enterprise tenants configure policies that block all third-party app consent - that is a customer-side decision.invalid_grant errors and replace old refresh tokens with the new one returned in each token response.prompt=consent to your authorization URL to force a fresh consent screen. AADSTS90099: The entire application has not been authorized in that tenant - the tenant admin needs to pre-approve your app. Send the admin consent URL to the customer's IT admin. Both errors are common in enterprise scenarios where tenants restrict user consent.Mail.ReadWrite and Mail.Send in the same scope string. Note that Mail.ReadWrite and Mail.Send are separate scopes - having read/write access does not automatically grant send permission. Always include offline_access to ensure you receive a refresh token. See the email API page for implementation details.