If your Outlook/Graph calendar integration returns 401 Unauthorized, the token you’re sending can’t be used for Microsoft Graph. This guide walks you through fast checks, exact Azure settings, and a minimal code patch (MSAL + Flask) that fixes the issue.

Quick Fix: Solve 401 Unauthorized
- Request the correct scopes:
https://graph.microsoft.com/Calendars.ReadWriteandoffline_access. - Use the right authority:
- Work/School (Entra ID):
https://login.microsoftonline.com/<tenant-id> - Personal Microsoft accounts allowed? Switch app to support them and use
.../common(or.../consumers).
- Work/School (Entra ID):
- Redirect URI must be Web and exact (e.g.,
http://localhost:5000/callback). - Refresh token required: you only get it if you include
offline_access. - Decode the token:
audmust behttps://graph.microsoft.com;scpmust includeCalendars.ReadWrite.
Symptoms
POST https://graph.microsoft.com/v1.0/me/events→ 401- Response may include a
WWW-Authenticateheader mentioning invalid_token, insufficient_claims, invalid_audience, or expired.
Why 401 Happens (Root Causes)
| Cause | What it looks like | Fix |
|---|---|---|
Wrong scope / missing offline_access | No refresh token, or scp in access token doesn’t include Calendars.ReadWrite | Request Graph-qualified scopes + offline_access; re-consent |
Wrong audience (aud) | Token aud ≠ https://graph.microsoft.com | Use https://graph.microsoft.com/<scope> names when requesting |
| Account type mismatch | Signing in with personal account but app is single-tenant | Change Supported account types + use .../common authority |
| Redirect URI mismatch | Login works inconsistently; refresh fails | Add exact Web redirect in App Registration |
| Expired/invalid client secret | Refresh fails after secret rotation | Create a new secret; update server env |
| Conditional Access / tenant restrictions | 401/403 with policy hints in WWW-Authenticate | Exempt the app or satisfy policy (device compliance, location, etc.) |
| Clock skew | Tokens appear “expired” immediately | Sync server clock (NTP) |
Azure Portal Checklist (Do These Exactly)
- App Registration → API permissions (Delegated)
Calendars.ReadWriteoffline_access
(User must grant consent; tenant admin consent optional for these two.)
- Authentication
- Add
http://localhost:5000/callback(type Web, not SPA). - Leave “Implicit grant” unchecked for this scenario.
- Add
- Supported account types
- If you’ll sign in with personal Microsoft accounts, select Accounts in any organizational directory and personal Microsoft accounts.
- Use
.../commonas authority (details below).
- Certificates & secrets
- Verify the client secret is present and not expired.
- Grant admin consent (optional but handy in enterprise tenants)
- Confirms org-wide consent for those delegated scopes.
If your Azure App Registration already includes Calendars.ReadWrite and offline_access permissions, you’re on the right track—just ensure the requested scopes match these exactly in your code.
Minimal Code Patch (MSAL + Flask)
Use Graph-qualified scope names and include offline_access. Ensure your authority matches who signs in.
# CONFIG
SCOPES = [
"https://graph.microsoft.com/Calendars.ReadWrite",
"offline_access",
]
# If only your Entra tenant users will sign in:
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
# If personal Microsoft accounts may sign in, switch app to multi-tenant + personal in Azure,
# then prefer the common authority:
# AUTHORITY = "https://login.microsoftonline.com/common"
Use the scopes above for both authorization-code and refresh flows:
auth_url = cca.get_authorization_request_url(
SCOPES,
redirect_uri=REDIRECT_URI,
prompt="select_account" # use "consent" once to force re-consent if needed
)
result = cca.acquire_token_by_authorization_code(code, scopes=SCOPES, redirect_uri=REDIRECT_URI)
# Later, before calling Graph:
token_response = cca.acquire_token_by_refresh_token(refresh_token, scopes=SCOPES)
access_token = token_response["access_token"]
Add helpful diagnostics for 401:
if resp.status_code == 401:
return jsonify({
"status": 401,
"error": "unauthorized",
"www_authenticate": resp.headers.get("WWW-Authenticate"),
"details": try_json(resp)
}), 401
And fix the default end time (make it +1h) to avoid invalid date scenarios:
from datetime import datetime, timedelta
now = datetime.utcnow()
start_dt = request.args.get("start") or (now + timedelta(minutes=5)).isoformat(timespec="seconds") + "Z"
end_dt = request.args.get("end") or (now + timedelta(hours=1)).isoformat(timespec="seconds") + "Z"
tz = request.args.get("timezone", "UTC") # If you use Z, prefer UTC here
Validate Your Token (One-Time Sanity Checks)
- Decode access token at jwt.ms (never share secrets):
aud→ https://graph.microsoft.comscp→ contains Calendars.ReadWriteexp→ in the future
- Call Graph with bearer token (curl)
curl -i https://graph.microsoft.com/v1.0/me \
-H "Authorization: Bearer <ACCESS_TOKEN>"
- If this is 200, your token is valid for Graph.
- If it’s 401, the
WWW-Authenticateheader tells you the exact reason.
Special Cases
- Conditional Access Policies: If your tenant enforces device compliance/location/MFA for Graph, test from a compliant device or create a policy exclusion for your dev app.
- Personal Microsoft Accounts: Require app to support personal accounts and
AUTHORITY=.../common(or.../consumers). - Service accounts / background jobs: For app-only (daemon) scenarios use Client Credentials and Application permissions (e.g.,
Calendars.ReadWrite) plus application access policy for shared mailboxes. The code flow differs.
FAQ: 401 unauthorized microsoft graph outlook calendar
Q: I get 403 instead of 401.
403 usually means the token is valid, but you lack permission for that resource (e.g., CA policy, mailbox isn’t accessible, or using app-only without mailbox access policy). Check WWW-Authenticate and Graph error body.
Q: I never get a refresh token.
Include offline_access in the requested scopes and make sure your redirect URI type is Web. Re-run auth with prompt=consent once.
Q: My token has audience api://<something> instead of Graph.
You requested scopes for a different resource (likely your custom API). Use Graph-qualified scopes as shown.
Read More:
- Fix Azure Login Error AADSTS5000225 in Microsoft Entra ID
- How to Backup Azure SQL Managed Instance to Blob Storage via SSMS
- How to Use Azure NetApp Files for OT Data Transfer to Azure Cloud
- Fix: az vm run-command Not Working in Azure CLI
- Azure VM Series Retirement 2028 Explained: F, Fs, Lsv2, G, Av2, and B-Series Migration Guide
