sauble.ai

Primer on Headless OAuth 2.0

What happens when there is no browser — or no human at all? A companion to the OAuth 2.0 & OIDC primer: the same cast at example.com and AcmeNetworks, now covering machines, CLIs, and long-running services — and ending where the MCP specification picks up.

Varma Chanderraju · Sauble Engineering · July 2026

Series: 1 · OAuth 2.0 & OIDC  —  2 · Headless OAuth 2.0  —  3 · MCP Authorization

Part 1 · The question

“Headless” means two very different things

The Authorization Code flow from the first primer assumed a human (Alice) with a browser to bounce around. But OAuth 2.0 was designed as a family of grant types — recipes for obtaining tokens — and only some of them need a browser. Before picking a recipe, ask the one question that decides everything:

Situation A

There is no user at all

A nightly batch job, one microservice calling another, a CI pipeline. The application acts as itself — it is both the client and, effectively, the resource owner. Recipe: Client Credentials grant. OIDC does not apply — there is no human to identify.

Situation B

A user exists — but not a local browser

A CLI on a server, a smart TV, an IoT console. Alice is still the principal; the device just can't run the redirect dance. Recipe: Device Authorization grant — the browser is moved to another device, not eliminated. OIDC applies when openid is requested and the provider supports it.

New cast members join the story (Okta, Microsoft, and Alice return from the first primer):

Acme Reporter
machine client — no user

AcmeNetworks' nightly service that pulls telemetry and emails reports. Runs at 03:00; nobody is awake. Star of Situation A.

acmectl (CLI)
device client — user, no browser

Acme's command-line tool, running on a headless jump box over SSH. Alice drives it, but it can't open a browser window. Star of Situation B.

Telemetry API
resource server

The API both clients want to call (api.acmenetworks.com). It accepts access tokens minted by Okta and validates them offline via JWKS — it never talks to clients about identity.

Okta (example.okta.com)
authorization server

Same hub as before. For machines it acts as a credential-checking token mint; for the CLI it orchestrates a remote human approval — federating to Microsoft exactly as in the first primer.

Part 2 · Situation A, animated

Client Credentials — the machine-to-machine grant

The simplest grant in the family (RFC 6749 §4.4). No redirects, no front channel, no consent screen: one back-channel call and the client has a token. What replaces the human login is client authentication — the service proving its own identity to Okta. Step through:

Where did OIDC go? Nowhere — it was never here. OIDC answers “which human logged in?”; with no human there is no ID token, no nonce, no UserInfo. The token's sub claim is the service. Authorization comes from an admin granting the app scopes at registration time (Entra calls these application permissions, as opposed to delegated permissions), not from user consent. Identity of machines is client authentication itself.

How machines prove who they are

Since the client secret is now the only credential in the flow, its quality matters. Three tiers, in ascending order of hygiene:

Also note what's absent from the token response: a refresh token. Machines don't need one — they hold their own credential and simply ask for a fresh access token whenever the old one expires.

Part 3 · Situation B, animated

Device Authorization — the “go to this URL on your phone” grant

Alice SSHes into a jump box and runs acmectl login. The CLI can't open a browser — but Alice has a phone. The Device Authorization grant (RFC 8628) splits the flow across two devices: the headless client polls while the human approves elsewhere. You've used this every time a TV showed you a short code:

The browser wasn't eliminated — it was relocated. Step 6 is the entire first primer replayed on Alice's phone: Okta session check, IdP routing rule, redirect to Microsoft, password + MFA, signed assertion back. Because a real human authenticated, OIDC can ride along: acmectl requested openid, so the CLI ends up holding an ID token that says alice@example.com. az login --use-device-code works exactly this way. (gh auth login uses the same device grant but plain OAuth — GitHub issues an access token, no ID token. The grant relocates the browser; identity comes only when OIDC is layered on top.)

The one attack to know: user-code phishing. The human approves on a different device from where the request originated, so an attacker can run the flow themselves and trick a victim into blessing the attacker's user_code (“enter this code to see the shared document…”). Defenses: short code lifetimes, showing the client name and location on the approval page (step 7), and conditional-access policies at the IdP. If a code arrives by email or chat and you didn't just type a command — don't enter it.

Part 4 · Acting for a user who isn't there

Refresh tokens and token exchange

There's a third headless pattern, subtler than the first two: the user was here once, and the software keeps acting for her afterward. Alice approves the CLI on Monday; it still works on Friday. No new flow is needed — this is the offline_access scope and the refresh token from the first primer doing their job on the back channel:

// Quietly, whenever the access token nears expiry — no Alice required:
POST https://example.okta.com/oauth2/v1/token
grant_type=refresh_token
&refresh_token=8xLOxBtZp8
&client_id=acmectl        // public client — no secret; rotation is its protection

← { "access_token": "eyJ…(fresh, 1h)",
    "refresh_token": "9mKPqCu1r3" }  // rotated: old one is now dead

Notice the rotation: for a public client like this CLI, OAuth 2.1 requires refresh tokens to be single-use (or sender-constrained), and rotation is the common pattern for confidential clients too. Reusing a spent token signals theft — Okta then revokes the whole grant. The user's control point is central: when Alice leaves example.com, disabling her in Entra/Okta kills every refresh token she ever granted, across every app, at the next renewal.

Token exchange — delegation through a service chain

Last pattern: Alice's token arrives at service A, and A must call service B as Alice. Forwarding her token is wrong — it was minted with aud: A, and B should reject it (audience checking exists precisely to stop tokens wandering). The clean answer is OAuth Token Exchange (RFC 8693; Entra's “on-behalf-of” flow is the same pattern, though implemented via Microsoft's own JWT-bearer profile rather than RFC 8693 itself):

// Service A trades Alice's inbound token for a downstream one:
POST https://example.okta.com/oauth2/v1/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=eyJ…            // Alice's token, aud: service-A
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=service-B
&client_id=service-A&client_secret=●●●

← { "access_token": "eyJ… sub: alice, aud: service-B,
     act: { \"sub\": \"service-A\" }" }  // 'act' = who is acting for her

The minted token still says sub: alice but now carries an act (actor) claim recording that service A is acting on her behalf — the delegation chain stays auditable. Keep this one in your pocket: agents calling tools that call other services is exactly this shape.

Part 5 · The dead end, and the cleanup

ROPC — the headless flow you must not use

Deprecated — removed in OAuth 2.1

Resource Owner Password Credentials

The app collects Alice's username and password and POSTs them to the token endpoint (grant_type=password). It “works” headlessly — which is why legacy scripts and old SDKs are full of it — but it is the password anti-pattern wearing OAuth's badge: the client sees the password, MFA can't run, and federation is impossible (Okta can't redirect a POST to Microsoft — the Okta→Entra hop from the first primer literally cannot happen).

Use instead

The right recipe per situation

  • No user → Client Credentials
  • User, no local browser → Device flow
  • User approved earlier → Refresh token
  • Service chain on user's behalf → Token exchange
  • CLI on a machine with a browser → plain auth code + PKCE with a localhost redirect (what gcloud auth login does)

OAuth 2.1 — the consolidation

OAuth 2.1 is not a new protocol; it's the 2.0 spec with fifteen years of hard lessons folded in and the footguns removed. Everything in these two primers is already 2.1-shaped. The deltas worth knowing:

Why does 2.1 matter here? Because the MCP specification is written against OAuth 2.1, not 2.0 — which is the bridge into the MCP world.

Part 6 · Reference

Which grant, when — the decision map

Ask yourselfGrantChannelOIDC / ID token?You've seen it as
Human, browser on this device?Authorization Code + PKCEfront + backYesEvery “Sign in with…” button; first primer
Human, browser on another device?Device Authorizationback + phoneProvider-dependent — yes with openid (Entra); GitHub-style is OAuth-onlyaz login --use-device-code, TV logins
No human at all?Client Credentialsback onlyNo — client identity via client authCron jobs, service mesh, CI
Human approved earlier, absent now?Refresh Tokenback onlyFrom the original login“Stay signed in”, background sync
Acting for a user across services?Token Exchange (RFC 8693)back onlyCarried in sub + actEntra on-behalf-of (same pattern, Microsoft-specific profile), gateway → backend
Tempted to collect the password?Stop — ROPC is removedLegacy scripts you should refactor

Part 7 · Where MCP picks up

How the MCP spec builds on all of this

The Model Context Protocol's authorization layer is deliberately not an invention — it's a profile of OAuth 2.1 with a handful of discovery RFCs made mandatory. Every concept maps onto something you now know:

MCP conceptWhat it is in OAuth terms
MCP serverAn OAuth 2.1 resource server — the Telemetry API from the diagrams above. It accepts Bearer tokens and validates them; it does not log users in.
MCP client (Claude, an IDE, an agent)An OAuth 2.1 client — Acme's web app / acmectl, depending on context.
Authorization serverSame role as Okta in the diagrams above — and explicitly allowed to be a separate, existing enterprise IdP: an Okta→Microsoft chain like the one in the first primer plugs straight in.
Interactive use (you driving Claude)Authorization Code + PKCE — PKCE required, per 2.1.
Headless agents / service integrationsClient Credentials — Part 2 of this primer.
Agent works while you sleepRefresh tokens; multi-hop tool chains are shaped like token exchange.

What MCP adds is mostly answers to a bootstrapping question the flows above never had to face. AcmeNetworks' developers configured Okta by hand: they registered a client, copied a client_id, wired the redirect URI. An MCP client can't do that — it meets servers it has never seen before at runtime. So the spec mandates a discovery chain:

  1. 401 + WWW-Authenticate — the MCP server rejects the bare request and points at its metadata.
  2. Protected Resource Metadata (RFC 9728, mandatory) — a /.well-known/oauth-protected-resource document where the server declares “here is the authorization server that protects me.” The resource-server cousin of OIDC's discovery document from the first primer.
  3. Authorization Server Metadata (RFC 8414) — the client fetches the AS's own well-known document for endpoints and keys, exactly as before.
  4. Client identification — the client introduces itself without pre-registration: historically via Dynamic Client Registration (RFC 7591), which the 2026-07-28 spec revision deprecates in favor of Client ID Metadata Documents — the client's ID is a URL pointing at a document describing it (name, redirect URIs, keys). Registration becomes “dereference and cache.”
  5. Resource Indicators (RFC 8707, mandatory) — the client adds resource=https://that-mcp-server to token requests, so the token is minted with a single-server audience. One MCP client talks to many servers; this is the audience-checking discipline from Part 4 preventing a token for server X from being replayed at server Y.

Why MCP is stricter than classic OAuth deployments: the threat model inverted. In the flows above, one app talked to one trusted IdP it was configured for. An MCP client talks to many servers, some of which may be malicious — so the spec hardens the client side: mandatory PKCE, mandatory audience binding via resource indicators, and (in the 2026-07-28 revision) mandatory iss validation on authorization responses (RFC 9207) to defeat mix-up attacks where one rogue server tries to confuse the client about who is answering. Same protocol, defensive posture.

That's the full runway. The third primer in this series — Primer on MCP Authorization — walks exactly that, end to end: an MCP client meeting a brand-new MCP server, discovering its authorization server, identifying itself, and landing tokens, with every step built from the two primers so far.

Part 8 · Go deeper

Sources & further reading