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.