How to Fix 401 Unauthorized Error in Microsoft Graph Outlook Calendar Integration

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.

How to Fix 401 Unauthorized Error in Microsoft Graph Outlook Calendar Integration
How to Fix 401 Unauthorized Error in Microsoft Graph Outlook Calendar Integration

Quick Fix: Solve 401 Unauthorized

  • Request the correct scopes: https://graph.microsoft.com/Calendars.ReadWrite and offline_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).
  • 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: aud must be https://graph.microsoft.com; scp must include Calendars.ReadWrite.

Symptoms

  • POST https://graph.microsoft.com/v1.0/me/events401
  • Response may include a WWW-Authenticate header mentioning invalid_token, insufficient_claims, invalid_audience, or expired.

Why 401 Happens (Root Causes)

CauseWhat it looks likeFix
Wrong scope / missing offline_accessNo refresh token, or scp in access token doesn’t include Calendars.ReadWriteRequest Graph-qualified scopes + offline_access; re-consent
Wrong audience (aud)Token audhttps://graph.microsoft.comUse https://graph.microsoft.com/<scope> names when requesting
Account type mismatchSigning in with personal account but app is single-tenantChange Supported account types + use .../common authority
Redirect URI mismatchLogin works inconsistently; refresh failsAdd exact Web redirect in App Registration
Expired/invalid client secretRefresh fails after secret rotationCreate a new secret; update server env
Conditional Access / tenant restrictions401/403 with policy hints in WWW-AuthenticateExempt the app or satisfy policy (device compliance, location, etc.)
Clock skewTokens appear “expired” immediatelySync server clock (NTP)

Azure Portal Checklist (Do These Exactly)

  1. App Registration → API permissions (Delegated)
    • Calendars.ReadWrite
    • offline_access
      (User must grant consent; tenant admin consent optional for these two.)
  2. Authentication
    • Add http://localhost:5000/callback (type Web, not SPA).
    • Leave “Implicit grant” unchecked for this scenario.
  3. Supported account types
    • If you’ll sign in with personal Microsoft accounts, select Accounts in any organizational directory and personal Microsoft accounts.
    • Use .../common as authority (details below).
  4. Certificates & secrets
    • Verify the client secret is present and not expired.
  5. 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)

  1. Decode access token at jwt.ms (never share secrets):
  2. 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-Authenticate header 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:

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *