---
title: "Connection: App-Only vs Delegated Auth — Graph API and MSAL"
connects:
  - "concepts/microsoft-graph-api-mailbox-migration"
  - "concepts/msal-vanilla-js-pkce"
  - "tech-patterns/azure-ad-msal-auth"
sources:
  - "daily/2026-04-16.md"
created: 2026-04-16
updated: 2026-04-16
---

# Connection: App-Only vs Delegated Auth — Graph API and MSAL

## The Connection

Microsoft Azure AD supports two fundamentally different OAuth flows — app-only (client credentials) and delegated (user) — and the correct choice depends entirely on whether a human user is present in the flow. MSAL.js in a browser SPA always uses delegated auth (the user logs in). Graph API scripts for admin tasks like mailbox migration must use app-only auth. Using the wrong flow causes a 403 that appears to be a permissions problem but is actually an authentication architecture problem.

## Key Insight

**Delegated tokens are bounded by what the user themselves can do.** A delegated token for user A cannot read user B's mailbox, even if the app has `Mail.Read.All` *delegated* permission — because user A doesn't have access to user B's mailbox. Delegated `Mail.Read.All` means "read mail for any user this specific user is authorized to impersonate" — which is typically nobody else.

**App-only tokens represent the application itself**, not any user. With `Mail.Read.All` as an *application* permission (admin-consented), the app can read any mailbox in the tenant. This is the correct architecture for server-to-server operations: email migration, backup, monitoring.

The error from using the wrong flow: `403 Forbidden` with body `{"code": "ErrorAccessDenied", "message": "Access is denied."}`. This looks like a permissions error, leading developers to add more permissions — which doesn't help because the problem is the flow type, not the permission name.

## When to Use Each Flow

| Scenario | Flow | Why |
|----------|------|-----|
| User logs into web app | Delegated (MSAL.js PKCE) | Token is for the user's own data |
| Script reads all mailboxes | App-only (client credentials) | No user present; needs cross-tenant access |
| Server validates user's token | App-only for JWKS validation | Server-side, no user interaction |
| User approves access to their own email | Delegated | User consents explicitly |
| Bulk email export/migration | App-only | Admin task, no interactive user |
| LibreChat / SSO login | Delegated (OpenID) | User must authenticate themselves |

## Evidence

- **Delegated fails on shared mailboxes (2026-04-16):** `list_mailboxes.py` initially used a delegated token. Worked for the primary mailbox owner but returned 403 for all 8 shared mailboxes. Rewriting to app-only with `Mail.Read.All` application permission resolved it immediately.
- **MSAL.js SPA (2026-04-15):** `social-reporting-tool` and HP Prod Tracker both use MSAL.js delegated flow — correct because a human user is authenticating and the app only reads that user's data.
- **JWKS validation (both):** The backend validates tokens using the JWKS endpoint — this is not "delegated" or "app-only", it's offline validation using Azure's public keys. It works for tokens from either flow.

## Diagnostic Heuristic

When you see 403 from Graph API:

1. Is there a human user in the flow right now? → Use delegated
2. Is this a server script or background job? → Use app-only
3. Did you check **application** permissions vs **delegated** permissions in Azure Portal? → They have the same names but are different grant types
4. Did you grant admin consent for application permissions? → Required; delegated permissions sometimes work without admin consent

## Related Concepts

- [[wiki/concepts/microsoft-graph-api-mailbox-migration]] — app-only implementation for M365 mailbox migration
- [[wiki/concepts/msal-vanilla-js-pkce]] — delegated flow implementation for SPAs
- [[wiki/concepts/librechat-openid-auth]] — delegated OpenID flow in LibreChat
- [[wiki/tech-patterns/azure-ad-msal-auth]] — Azure AD patterns across Oliver Agency projects
