Skip to content

How MCP authorization works, end to end

MCP authorization step by step: the 401 challenge, discovery, client registration, PKCE with S256, consent, and the bearer token that follows.

Uday Gajavalli 15 min read
An AI client's first unauthenticated request to an MCP endpoint, answered with a 401 challenge pointing at a protected resource document

What happens when an MCP client sends its first request?

It gets a 401, and the 401 tells it where to go next. Someone pastes https://api.elaichi.ai/mcp into Claude and presses Connect. No client ID, no secret and no API key is typed anywhere. That is the design: MCP authorization is arranged so the first refused request carries the directions for the second.

A note before the detail: this post mixes two layers. Some of what follows is mandated by the MCP and OAuth specs. Anyone implementing MCP authorization must do it that way, such as publishing protected resource metadata and requiring PKCE. Some of it is Elaichi's own implementation choice: token byte length, specific TTLs, rate limit numbers, scope names. Where a number or rule is Elaichi-specific rather than spec-mandated, it is marked (Elaichi). Unmarked protocol mechanics apply to any compliant MCP server.

MCP is the protocol AI clients use to call tools on a server. An endpoint is the single URL a client posts to. Elaichi serves MCP at one endpoint, POST https://api.elaichi.ai/mcp (Elaichi: one endpoint for every organization). Which organization, which person and which tools a client reaches comes from the OAuth grant behind its token, not from the URL. OAuth is the standard that lets a person approve an app's access without handing it a password.

The endpoint speaks JSON-RPC 2.0 over MCP's Streamable HTTP transport. It is stateless: no session between requests, no session id, no replay of a last event id. That statelessness is Elaichi's design choice within what the transport spec permits, not something Streamable HTTP requires. Every request must carry an Elaichi MCP OAuth access token as a Bearer token in the Authorization header. Nothing else works. An Elaichi API token, a browser session or a pasted key all get HTTP 401.

The refusal is not bare. It carries WWW-Authenticate: Bearer resource_metadata="https://api.elaichi.ai/.well-known/oauth-protected-resource", the RFC 9728 pointer a client follows to find the authorization server. Every MCP server must implement RFC 9728 protected resource metadata. It must advertise it in at least one of two ways: the resource_metadata parameter on the WWW-Authenticate challenge, or a well-known URI (MCP authorization, revision 2026-07-28, checked November 2026). Elaichi uses the header. A server that advertises only the well-known URI is equally compliant, so a client cannot assume a challenge header will be there. With no token at all, the challenge carries no error and no scope, and the message reads "Authorization required". With a bad token it adds error="invalid_token" and the message "MCP access token is invalid, expired, or its grant was revoked". An unknown token, an expired token, a revoked grant and a deactivated account all get that same sentence. That is deliberate, so the response tells an attacker nothing about which of the four it was.

The address itself carries no credential. The protected resource document advertises bearer_methods_supported: ["header"], so the token travels in a header and never in a query string. GET and DELETE on the endpoint answer 405 with Allow: POST. For what one authorized call then looks like on the wire, see the walk through JSON-RPC over Streamable HTTP.

What the protected resource document points at

It names the resource and the authorization server, and that is enough for a client to discover everything else. Elaichi publishes three public discovery documents at https://api.elaichi.ai, each served with Cache-Control: public, max-age=3600 (Elaichi: 1-hour cache).

The protected resource document (RFC 9728) names the resource https://api.elaichi.ai/mcp, the authorization server https://api.elaichi.ai, the six scopes, bearer_methods_supported ["header"], and resource documentation at https://elaichi.ai/docs/.

The authorization server document (RFC 8414) advertises the issuer and the endpoints /oauth/authorize, /oauth/token, /oauth/register, /oauth/revoke and /oauth/userinfo. It advertises response type code only, grant types authorization_code and refresh_token, PKCE method S256 only, and client authentication none, client_secret_post or client_secret_basic.

The OpenID Connect Discovery document repeats the issuer, endpoints, PKCE rule and scopes. It adds subject_types_supported ["public"] and the claims sub, email and email_verified. It has no jwks_uri, because Elaichi issues no ID token (Elaichi choice). The access token is opaque, and identity comes from /oauth/userinfo rather than from anything a client can parse out of the token.

The scope list is short and closed: six values total, no custom scopes accepted.

Scope Unlocks Notes
mcp:read Read-oriented MCP calls Default scope if a request sends none
mcp:write Write-oriented MCP calls Requesting it auto-locks mcp:read on in the same grant
mcp:destructive Delete / irreversible MCP calls Never pre-ticked on the consent screen
mcp:tools Running connected-app tools via search_tools/execute_tool, shown as "Use your connected apps" Adds a "Toolboxes" row to pick which toolboxes the app reaches
openid sub claim via /oauth/userinfo No Elaichi operation reachable with this alone
email email, email_verified claims via /oauth/userinfo Requires openid to be meaningful

A scope parameter is space separated. A request with no scope asks for mcp:read alone, and any value outside those six, offline_access included, returns invalid_scope.

Client ID Metadata Documents, or dynamic client registration?

Against Elaichi, it is dynamic client registration, because Elaichi does not implement Client ID Metadata Documents. The spec puts it the other way round. Authorization servers and clients SHOULD support Client ID Metadata Documents, while dynamic client registration is a MAY, kept for backwards compatibility. A client can obtain a client ID three ways: through a metadata document, through pre-registration with the server, or through dynamic client registration (MCP authorization, revision 2026-07-28, checked November 2026). So an MCP server does not have to expose a registration endpoint at all.

Elaichi takes the backwards-compatible path (Elaichi choice). No Elaichi discovery document advertises client_id_metadata_document_supported, and a client_id must be one Elaichi issued. Any other value, a metadata document URL included, gets invalid_client with "Unknown client_id".

Clients branch on exactly that signal. Anthropic's documentation says Claude uses a Client ID Metadata Document only when the authorization server's metadata advertises both client_id_metadata_document_supported: true and "none" in token_endpoint_auth_methods_supported. If either is missing, Claude falls back to dynamic client registration (Claude's connector authentication docs, checked October 2026). OpenAI documents the same shape for Codex. It prefers a metadata document when the server advertises support and the callback is a supported loopback URL, otherwise dynamic client registration, and it skips registration entirely when a client ID is configured (Codex MCP docs, checked October 2026).

So both land on POST https://api.elaichi.ai/oauth/register (RFC 7591). Registration is open and needs no Elaichi credential. It is the only way an OAuth client for the endpoint is created, which is why nobody connecting a client has a client ID, secret or header to look up.

The registration body takes 1 to 10 redirect URIs, each at most 2,048 characters, an optional client_name of up to 200 characters, and a few optional fields; unknown fields are ignored (Elaichi-specific limits; RFC 7591 does not set these numbers). A successful registration answers 201 with the client_id. A redirect URI may be https on any host, http on a loopback host (localhost, 127.0.0.1 or [::1]), or a private-use scheme such as cursor:// or vscode://. Fragments are refused, and so are javascript:, data:, file:, blob:, ws: and wss:, each with invalid_redirect_uri.

The default token_endpoint_auth_method is none: a public client that proves itself with PKCE alone. A confidential client receives its secret once, in the registration response, and Elaichi keeps only an HMAC of it, never the plaintext secret.

Open registration carries a trade-off. Anyone can register an app under any name and logo. Elaichi says so on the consent screen in those words, and it moderates the registered name, so a flagged name becomes "MCP client". It also ties its bundled logos to redirect hosts rather than to names. Only seven clients can earn one, among them Claude, ChatGPT and Cursor, as of October 2026. A client that registers only loopback redirects never earns a bundled logo, because a loopback address proves nothing about who is on the other end. Any client may supply an https logo of its own, which the console draws first. A name or a logo on a consent screen is a claim, not evidence.

PKCE is mandatory and S256 is the only method accepted. That is Elaichi's policy choice; the PKCE RFC itself permits plain as a fallback, and Elaichi does not implement it. An authorization request is GET /oauth/authorize with client_id, redirect_uri, response_type=code, code_challenge and code_challenge_method=S256, plus optional scope, state and resource. A missing code_challenge_method is refused rather than assumed to be S256. RFC 7636 sets the 43 to 128 character range for the code verifier alone. An S256 challenge is the base64url of a SHA-256 digest, so it is always 43 characters. Elaichi checks the challenge against the same 43 to 128 range (Elaichi), which an S256 challenge meets by construction.

The order of checks matters. Elaichi validates client_id and redirect_uri first. An unknown client ID, or a redirect URI the client did not register, gets a plain 400 JSON error and is never redirected. That way the error path cannot be turned into an open redirect. Every later error goes back to the registered redirect URI as error and error_description, with state echoed.

Redirect matching is exact: no prefix matching, wildcards or normalization. The one exception is a loopback http address, which may use a different port, because native clients bind a fresh OS-assigned port each run (RFC 8252 section 7.3). Scheme, host, path and query must still match exactly, so 127.0.0.1 never matches a registered localhost. The resource parameter (RFC 8707) is optional, and when sent must be on the origin https://api.elaichi.ai, or it fails with invalid_target.

After validation, Elaichi parks the request server-side under an opaque id. It then sends the browser to the consent screen at https://app.elaichi.ai/oauth/consent, with that id as the request_id query parameter. The browser carries only the id, never the client's parameters, so nothing can be edited on the way.

The consent screen is where a person makes the real decisions. They pick one organization, and that grant covers only that organization. They tick scopes: "Delete things" (mcp:destructive) is never pre-ticked, and ticking write or delete locks read on. If "Use your connected apps" (mcp:tools) is ticked, a "Toolboxes" row opens a sheet that asks which toolboxes the app may reach, either "Everything I can use" (the person's toolboxes and system toolboxes for every connected app, including ones added later) or 1 to 50 named ones. It is all one page, with "Don't allow" and "Allow" buttons. The consent request lasts 30 minutes (Elaichi), which is long enough to open another tab, build a toolbox and come back. Consent can narrow what the client asked for, never widen it, and the server refuses any scope the client did not request.

Two scopes get no checkbox. When a client asks for openid and email, the screen states that the app always sees the person's user ID and email address whatever they untick. Those two only unlock /oauth/userinfo, which returns sub, the person's Elaichi user id, and with email also email and email_verified. A grant holding only those two reaches no Elaichi operation and no connected tool.

Only a signed-in person in the Elaichi web app can view, approve or deny the request. The consent routes refuse Elaichi API tokens and MCP access tokens, so no client or script approves its own request. The authorization code the approval produces lasts 60 seconds (Elaichi).

Audience, lifetime and rotation: what the bearer token is

The token is opaque, short lived, and rotated on every refresh. POST /oauth/token returns an access_token, token_type Bearer, expires_in, a refresh_token and the granted scope, with Cache-Control: no-store. Tokens are random 32-byte values written as mcp_at_ or mcp_rt_ plus 64 hex characters. Elaichi stores only a keyed HMAC of each one, never the plaintext token. Where the credentials for the connected apps behind those tokens sit is a separate question, covered in where an agent's OAuth credentials live.

Audience checking is narrower than the usual description. The resource value is checked when the authorization is requested and when the code is exchanged. Once a token is issued, the endpoint does not re-check the token's audience on each call. These are bearer tokens: Elaichi does not bind them to a device or sender (no DPoP, no mTLS). Anyone holding a valid one can call the endpoint with it until it expires or the grant is revoked.

All timing values below are Elaichi's current defaults, confirmed October 2026. They may change without changing the protocol mechanics described elsewhere in this post:

Artifact Lifetime / limit Behavior on reuse or expiry
Access token 1 hour (expires_in 3600) An expired token gets 401 invalid_token
Refresh token 30 days, single use Reuse within 30 days of its prior use revokes the whole grant; a new refresh token is issued on every refresh
Authorization code 60 seconds, single use Replay within 10 minutes revokes the whole grant
Consent request 30 minutes Expired request must be restarted from /oauth/authorize
"Last used" timestamp Debounced 5 minutes Can lag true last-use by up to 5 minutes
Access token rate limit 120 requests / 60 seconds per token 429 with Retry-After: 60
OAuth endpoint rate limit 30 requests / 60 seconds per client ID (or per IP if no client ID in the request) 429 with Retry-After: 60

The grant itself has no expiry date. What runs out is the refresh token, so a client idle for 30 days has to connect again.

Revocation is a read, not a search: removing or suspending a member revokes every live grant in the same transaction as the membership change. Every MCP request re-reads the grant from the organization's store. So disconnecting an app under Settings, Connected apps fails its very next request with a 401. A client can also end its own connection at POST /oauth/revoke (RFC 7009) with either token. Either one revokes the whole grant, and only the client the token belongs to can do it. The endpoint always answers 200, unknown tokens included, so nobody can probe it for valid tokens. Removing or suspending a member revokes their grants in the same change that ends the membership, and reactivating them does not bring the grants back. Role and restriction changes travel differently: they reach the endpoint about two minutes.

A call the scopes do not cover is not a 401 or a 403. The endpoint answers HTTP 200 with an in-band tool error plus a WWW-Authenticate challenge carrying error="insufficient_scope" and the scope needed. That 200-with-in-band-error pattern is a deliberate deviation from the more common 403. It is chosen so the error surfaces inside the MCP tool-call response the model sees rather than as a transport-level failure. The refusal names the consent checkbox to tick and says the only fix is to reconnect, because a grant's scopes never change after consent. A refresh may ask for fewer scopes than the grant holds, never more.

Error reference

Trigger Response Error signal
No token sent 401 WWW-Authenticate: Bearer (no error param), "Authorization required"
Unknown / expired / revoked token, deactivated account 401 error="invalid_token", same message for all four cases
GET/DELETE on /mcp 405 Allow: POST
Unknown client_id or unregistered redirect_uri 400 (not redirected) Plain JSON error
Malformed/disallowed redirect_uri at registration 400 invalid_redirect_uri
resource not on https://api.elaichi.ai Redirect with error invalid_target
Scope outside the six supported Redirect with error invalid_scope
Call outside granted scope 200, in-band tool error error="insufficient_scope" + required scope
Rate limit exceeded 429 Retry-After: 60
Revoke on unknown token 200 No distinguishing signal (by design)

If your client is stuck on one of these, the OAuth error walkthrough covers the common ones in more detail.

What a person can see and change after the grant exists

Each approval creates its own grant, and nothing deduplicates. Connecting the same client twice produces two grants, each listed and disconnected separately. Each is labeled with the browser and operating system that approved it, such as "Chrome on macOS". Elaichi stores that label and never the raw user agent or an IP address.

Settings, Connected apps lists a person's own grants for the organization currently open. It has the columns App, Access, Toolboxes, Connected and Last used, a search by app name and a "Never used" filter. No permission is needed to see it, and nobody can see another member's grants there. "Last used" is stamped by token exchanges, refreshes and MCP requests. The write is skipped if the grant was stamped in the previous five minutes, so the value can run up to five minutes behind.

On an app's own page, the person can change which toolboxes the grant reaches, widening as well as narrowing, and the change applies on the app's next call. Scopes cannot widen without a new consent, so an app approved without "Use your connected apps" has nothing to edit there.

The lifecycle is audited. Elaichi writes oauth_grant.created at consent, with the client, the scopes and the toolbox reach. It writes oauth_grant.activated when the first token exchange happens, oauth_grant.updated when the toolboxes change, and oauth_grant.revoked when the person disconnects it. An approval whose code is never exchanged, because a callback was blocked or a browser closed, never appears in Connected apps and is revoked automatically.

MCP authorization end to end, on one endpoint

Put the steps together and the shape is one governed boundary rather than one server per app. Claude, ChatGPT, Cursor and Microsoft Copilot all sign in to the same address; Copilot reaches it through a Copilot Studio agent that adds the endpoint as a tool. There is no per-user, per-team or per-toolbox MCP URL, and no MCP server for anyone to create or revoke by hand. Each person connects their client once and approves their own grant, and that grant is what an admin or the person revokes.

Behind the endpoint sits one catalog of 650+ connectors in two kinds. Most are connectors Elaichi authors and runs itself. The rest are native MCP connectors: the vendor builds and runs its own MCP server, and Elaichi handles sign-in, access and audit in front of it. Elaichi never forwards its own MCP access token to such a server, so an upstream OAuth server only ever sees a token its own authorization server issued. An organization can also bring its own remote MCP server in under the same rules, which is the subject of running vendor MCP servers through one plane.

Every call made with a grant's tokens acts as the person who approved it, in the organization they picked. Permissions are looked up per request rather than frozen into the token, so the grant's scopes and the person's role must both allow a call. Restrictions on the role or on the person are applied before the call leaves Elaichi. Every call that reaches execution is written to the audit log (one entry for each connected-tool call that reaches execution (a call refused earlier writes none)), recorded under the surface mcp.

One honest gap in that record: a call refused at the scope check writes no audit entry, because the refusal happens before any executor runs. If audit completeness is the point of the exercise, read what an AI agent audit log must capture before you design around it.

What the handshake does not do

Authorization answers who the caller is and what they consented to. It does not answer three other questions, and treating it as though it does is where reviews go wrong.

It is not a read-only control. The mcp:tools scope ("Use your connected apps") runs every tier of connected tool, writes and deletes included. mcp:destructive ("Delete things") covers Elaichi's own deletes, not a connected app's. To hold a client to reads, pick toolboxes of read tools at consent, or block the write and delete tools with a restriction on a role or on one member. Nothing in the consent screen does it on its own. No client-side allowlist of tool names can single out one connected app either, because connected tools are never listed individually. They are reached through search_tools, read_tool, execute_tool and run_code. That indirection is explained in why execute_tool is only a naming indirection.

It is not a prompt-injection defense. Nothing on the MCP path scans tool results for injected instructions or second-guesses a call, and it could not: the endpoint receives a tool name and arguments, never the person's own prompt. That defense belongs to the client. The wider list is in MCP security risks.

It is not a way to run unattended. Elaichi's MCP sign-in supports only the authorization code grant with PKCE, plus refresh. There is no device code grant and no client credentials grant, so a new grant always needs a person at a browser.

One more practical limit. Elaichi speaks MCP revisions 2026-07-28, 2025-06-18, 2025-03-26 and 2024-11-05, and on the newest revision list results carry a 60-second cache hint. It advertises listChanged: false and sends no list-changed notification, so it cannot make a client refresh a cached tool list. How long a given client keeps its copy is that client's decision.

When you do not need any of this

If you are building one product's own MCP server, a control plane is the wrong layer. You need the mechanics above implemented once: protected resource metadata, an authorization server document, a way for clients to obtain a client ID, PKCE with S256, and token issue and rotation. That last one is a choice rather than a fixed menu, since a metadata document, pre-registration or a registration endpoint all satisfy it. A control plane in front of a single server adds a hop and a second place to configure access, with no corresponding benefit when there is only one server to govern.

If two people at your company connect one app each to Claude or ChatGPT, the native connectors in those products may be the whole answer. The authorization mechanics described in this post are mostly what those native connectors implement too, minus the shared catalog and cross-client audit log. The comparison is set out in Claude and ChatGPT Enterprise connectors versus a gateway, and the case for waiting is in when you do not need an MCP gateway yet.

The handshake starts paying for itself when several clients, several apps and several people meet. At that point, one grant per person and one audit log across all of them replaces per-client registration and per-server access review spread across laptops and teams. For the layer above this one, see what an MCP control plane is, or browse the connector catalog to see what sits behind the endpoint once the token is in hand.

FAQ

Frequently asked questions

What does an MCP server return when a request has no token?

HTTP 401 with a WWW-Authenticate challenge. Elaichi's endpoint returns `WWW-Authenticate: Bearer resource_metadata="https://api.elaichi.ai/.well-known/oauth-protected-resource"`, the [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) pointer a client follows to find the authorization server and start sign-in. The spec requires every MCP server to implement RFC 9728 and advertise it through that header parameter or a well-known URI, at least one of the two ([MCP authorization, revision 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization), checked November 2026). With no token the challenge carries no error and no scope; with a bad token it adds `error="invalid_token"`.

Does Elaichi support Client ID Metadata Documents for MCP authorization?

No. The spec says authorization servers and clients SHOULD support Client ID Metadata Documents, with dynamic client registration a MAY kept for backwards compatibility ([MCP authorization, revision 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization), checked November 2026). Elaichi takes the latter path: none of its discovery documents advertises `client_id_metadata_document_supported`, and a `client_id` must be one Elaichi issued at registration; any other value, including a metadata document URL, gets `invalid_client` with "Unknown client_id". Clients that prefer metadata documents fall back to dynamic client registration at `POST https://api.elaichi.ai/oauth/register`, which is open and needs no Elaichi credential; both [Claude](https://claude.com/docs/connectors/building/authentication) and [Codex](https://learn.chatgpt.com/docs/extend/mcp) document that fallback (both checked October 2026).

Is PKCE optional when connecting an MCP client to Elaichi?

No. [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) is mandatory and S256 is the only method accepted. An authorization request without `code_challenge_method` is refused rather than assumed to be S256, and `plain` is not implemented. RFC 7636 sets the 43 to 128 character range for the `code_verifier`; an S256 `code_challenge` is always 43 characters. Elaichi checks the challenge against the same 43 to 128 range, which an S256 challenge meets by construction.

Does an MCP endpoint check the token's audience on every call?

Elaichi's does not. The optional `resource` parameter ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)) is checked when the authorization is requested and when the authorization code is exchanged, not again on each call to the endpoint. The tokens are bearer tokens and are not bound to a device or sender, so what bounds them is the one-hour access token lifetime, refresh token rotation, and revocation taking effect on the next call.

How is an AI client's access to an MCP endpoint revoked?

By revoking the grant, not by changing a URL. In Elaichi, a person disconnects an app under Settings, Connected apps, or the client revokes either of its tokens at `POST /oauth/revoke` ([RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009)). Every request re-reads the grant, so the client's very next call fails with a 401. Removing or suspending a member also revokes their grants, and reactivating them does not restore the grants; they have to consent again.

How long does an MCP access token last, and what happens when it expires?

An Elaichi MCP access token lasts one hour. A client that runs longer presents its refresh token, which lives 30 days and works exactly once: every refresh returns a new refresh token and invalidates the one presented. Presenting a used refresh token within 30 days of its use revokes the whole grant, and so does presenting it from a different client than the one it was issued to.

Put AI to work in your own apps

14 days of Black, no credit card. Start with one app and one team.

When the trial ends, nothing is deleted. You move to the free Personal plan, and connections, roles and the audit log stay where they are.

Add one link to your AI app

The same link for everyone on your team.

https://api.elaichi.ai/mcp
  • ChatGPT
  • Claude
  • Cursor
  • Microsoft Copilot