~blogtwo-kinds-of-oauth-token

Microsoft Graph /me returns nothing: delegated vs app-only tokens

2026-01-20 · 6 min read · oauth · microsoft graph · security · integrations

If you have ever called /me with a working token and got an error back, you have hit the distinction this post is about. The token was valid. The permission was granted. The request still could not be answered, because /me requires a me, and an app-only token does not have one.

Every serious API integration eventually has to run in two modes, and they use different credentials with different semantics. Microsoft Graph is where I hit this most recently, but the shape is the same on Google Workspace, Slack, GitHub Apps, and anything else with an admin-consent model.

The two modes

  DELEGATED                         APP-ONLY
  user signs in                     no user involved

  Auth Code + PKCE                  client credentials
      │                                 │
      v                                 v
  token carries: app + user         token carries: app only
      │                                 │
      v                                 v
  sees what THAT USER can see       sees what the ORG granted
  /me works                         /me is meaningless
  consent: by the user              consent: by an admin, once

A delegated token represents a person using your software. It is bounded by that person’s own permissions, which is a security property you get for free: your code cannot read what the signed-in user could not have read anyway.

An app-only token represents your software acting as itself. There is no user to bound it, so it is bounded only by what an administrator consented to for the whole organization. That is a much bigger blast radius, and it is why admin consent is a deliberate speed bump.

The failure mode is treating them as interchangeable. They are not two configurations of one credential. They are two different identities.

Delegated App-only
Who it represents A signed-in user The application
/me Works No user context, fails
Bounded by That user’s permissions Admin-consented org permissions
Consent The user, at sign-in An admin, once, org-wide
Right for Interactive features Background sync, scheduled jobs
Wrong for Unattended workers Anything reaching the browser

One app registration, not five

My first instinct was to register a separate application per capability: one for login, one for calendar, one for files, one for the background worker. It felt tidy. It is the wrong shape.

A single registration supports all of it: OIDC login, authorization code flow with PKCE for delegated access, application permissions for the worker, API scopes if you expose your own, and multiple redirect URIs for local and production environments. Splitting it multiplies the number of consent grants to manage, secrets to rotate, and places for permissions to drift apart, while providing no isolation you did not already get from the delegated versus app-only split.

One registration, two flows, sharply separated in code:

  Web application                    Background worker
    Authorization Code + PKCE          client credentials
    -> delegated token                 -> application token
    -> user-scoped calls               -> admin-consented calls

The rule that keeps this safe is short. An app-only token must never reach a browser. It is not scoped to the user in front of the screen and it never will be. If it leaks client-side, it leaks organization-wide access rather than one person’s.

I tested the delegated path against /me, /me/events, and /me/drive/root/children before building on it, which is worth doing early: three endpoints tell you whether your consent, scopes, and token handling are right before you have written any sync logic on top of them.

Certificates over secrets for the worker

For the unattended flow I would use certificate-based client authentication in production rather than a long-lived client secret.

A client secret is a bearer string. It sits in an environment variable, gets copied into a CI configuration, appears in a log line somebody pastes into a chat, and it works for whoever holds it until it expires, which is usually in a year or two. Certificate auth means the worker proves possession of a private key without transmitting it, which removes the whole category of “the credential was readable in a place I did not expect”.

The operational cost is real: you now have key material with a lifecycle. For a credential that grants organization-wide access, that cost is correct.

Building without a live tenant

A separate problem, same integration. Graph development stalls when the internet is out, a test tenant is not ready, admin consent is pending, or when using real company data during UI work would be inappropriate.

The fix is an interface, not a flag scattered through the codebase:

interface GraphClient {
  getProfile():     Promise<UserProfile>;
  listEvents():     Promise<CalendarEvent[]>;
  listMessages():   Promise<EmailMessage[]>;
  listDriveItems(): Promise<DriveItem[]>;
}

Two implementations, MicrosoftGraphClient and MockGraphClient, selected once at composition. Business logic depends on the interface and never asks which mode it is in. The moment you write if (process.env.GRAPH_MODE === ...) inside a service, mock and live behaviour start to diverge and your tests stop telling you anything.

The mock earns its keep by simulating what the real API does badly, not what it does well. Happy-path fixtures are easy and nearly useless. What you want on demand is 401, 403, 429 with a retry-after, an expired refresh token, missing admin consent, a deleted source object, multi-page pagination, a partial sync, and a network timeout. Every one of those is a code path that otherwise gets written blind and first executes in production.

The dev bypass rule

Alongside the mock client sits a development auth mode that skips interactive sign-in and injects a known identity, role, and tenant. It is enormously convenient and it is a complete authentication bypass sitting in your codebase.

So it fails closed, loudly, at startup:

production environment + dev auth enabled = fatal startup error

Not a warning. Not a log line. The process refuses to start.

The reasoning is about which failure you prefer. A crashed deploy is noticed in seconds and fixed in minutes. An application running in production with authentication disabled looks completely healthy, serves traffic, and is discovered by someone other than you. Given a bypass that can be enabled by configuration, the only safe design is one where the dangerous combination cannot run at all.

This generalises past auth. Any switch that makes a system less safe for development convenience should be structurally impossible to leave on: debug endpoints, verbose responses that leak internals, fixture data seeding, an OTP value echoed back in the response to save you checking your phone. Every one of those has shipped to production somewhere, in a codebase where it was only ever a config value away.

Status

The delegated flow is implemented and tested against real endpoints. The production model - certificate auth for the worker, the full permission separation - is designed and partly built. Scope-level specifics change with the platform, so check current documentation rather than trusting a blog post about API behaviour, including this one.

What transfers

Three things hold for any API with an organizational consent model.

Decide which identity each code path runs as before you write it, because retrofitting the delegated versus app-only split means re-deriving every permission decision you have already made.

Depend on an interface, not on a mode flag, so that the mocked and real paths cannot drift.

And make unsafe development conveniences refuse to start rather than warn. A switch that only warns is a switch that will eventually be found on, by somebody who was not looking for it.