Back to blog
EngineeringSeptember 12, 2026· 13 min read· By James Okafor

Last updated: September 12, 2026

Shipping a Remote MCP Server: What OAuth 2.1 Actually Requires

PKCE, dynamic client registration, resource indicators, and a discovery chain that starts at a 401. The specs a remote MCP server must satisfy, and why each exists.

The Shape of the Problem

A local MCP server has no authentication problem. It runs as you, on your machine, and whatever it can reach, you could already reach. A remote one has the full problem: it must authenticate callers it has never met, running software it did not write, with nobody available to provision a client in advance. The user's entire intent is that they pasted a URL into their editor, and everything else has to fall out of that.

That constraint rules out most of the obvious designs. You cannot issue client credentials ahead of time, because there is no enrolment step. You cannot rely on a client secret, because every client here is public: an editor, a CLI, a desktop app, all of them shipping their code to users. And you cannot hand out a long-lived bearer key, because the thing you are trying to avoid is a secret living in a config file across three machines.

What follows is what we actually built for the PulsAPI MCP endpoint, which specification each piece comes from, and the two decisions that were not obvious until we had shipped the wrong version first. If you want the product view instead, that is the announcement.

The Discovery Chain Starts at a 401

The elegant part of the current design is that the client needs exactly one piece of information, the MCP URL. Everything else is discovered, and the discovery starts by failing.

An unauthenticated JSON-RPC POST gets a 401 carrying a WWW-Authenticate header with a resource_metadata parameter. The client follows that to the protected resource metadata document, which names the authorization servers that can issue tokens for this resource. It follows one of those to the authorization server metadata document, which names the registration, authorization, token, and revocation endpoints. From there it registers itself and runs the code flow. Four documents, all discoverable, none configured.

One detail is easy to get wrong and breaks clients silently. The protected resource metadata document must be path-suffixed to the resource it describes, so the metadata for /api/mcp lives at /.well-known/oauth-protected-resource/api/mcp and not at the bare well-known path. Serving only the bare path works with clients that guess and fails with clients that follow the RFC, which is the worst combination because it looks like the client's fault.

The other one is issuer equality. RFC 8414 compares issuers by exact string, so a trailing slash makes the issuer unequal to itself when a client echoes it back. We derive the issuer, every endpoint, and the token audience from a single base-URL property for that reason: a deployment then cannot half-move. That is not a hypothetical. We had already shipped an authorization server whose issuer named one origin while its endpoints pointed at another, and no client could complete against it.

1. Unauthenticated callPOST /api/mcp returns 401 with a WWW-Authenticate header naming the resource metadata.RFC 9728
2. Protected resource metadata/.well-known/oauth-protected-resource/api/mcp, naming the authorization servers.RFC 9728
3. Authorization server metadata/.well-known/oauth-authorization-server, naming registration, authorize, token, revoke.RFC 8414
4. RegistrationPOST to the registration endpoint, returning a client_id.RFC 7591
5. Authorization code with PKCEBrowser to the consent screen, code back, exchanged at the token endpoint.RFC 6749, RFC 7636
The full chain from one pasted URL to a usable token. Each step is a document the client reads rather than a value a user configures.

Dynamic Client Registration Is Not Optional Here

RFC 7591 registration is what makes pasting a URL work at all. Without it, somebody has to create a client record before the user's first attempt, and there is no moment in the flow where that could happen. So the registration endpoint is unauthenticated, which is the part that makes people nervous, so it is worth being precise about why it is acceptable.

A registration grants nothing. It creates a row with a client identifier and a set of redirect URIs, and that is the entire extent of it. No data is reachable until a signed-in person approves that client on a consent screen. The risks it does carry are table growth and redirect abuse, and both are answered at the endpoint instead of by putting a credential in front of it: at most 10 redirect URIs per client, every one validated against a safe-URI rule, requested scopes narrowed to what the server actually offers, and a per-caller registration budget of 20 per hour.

That 20 is deliberately tight and the token endpoint's budget is deliberately loose, at 120 per hour, because the two endpoints fail differently. Registration writes a durable row for anyone who asks, so its limit is about table growth, and a person connecting editors all afternoon does not register twenty clients. The token endpoint is where a stolen code or refresh token would be brute-forced, so its limit is about guessing, and it has to leave room for legitimate clients refreshing on their own schedules and for several agents sharing one office NAT.

Both limiters key on a SHA-256 digest of the resolved client address, never the address itself. The resolution reads the forwarded chain from the right, so a caller cannot rotate an X-Forwarded-For header to mint itself fresh buckets. Hashing matters for a reason that is easy to miss: a rate limiter on an account-free endpoint is, by default, a log of who tried to connect from where. Storing the digest keeps the limiter working and keeps that log from existing. The general version of this argument is in rate limiting strategies.

PKCE, S256 Only

Every client of this server is public, so there is no client secret and the code verifier is the only thing binding an authorization code to whoever requested it. That makes PKCE mandatory rather than recommended, and it makes the choice of method load-bearing.

We reject the plain method outright. Under plain, the challenge is the verifier, so anyone who can observe the authorization request can replay the exchange. It exists in the specification for constrained clients that genuinely cannot compute SHA-256, a category that does not include any MCP client, and accepting it would let the weakest client that connects set the server's security posture.

Two more properties are checked at exchange, not at issue, which is the right place because it is where a stolen code is spent. A code is single-use, so a replay finds it already consumed. And a code is bound to the redirect URI it was requested with, so a code lifted from one client cannot be redeemed through another's callback.

Codes live 60 seconds. They travel through a browser URL, which means they land in history, in referrer headers, and in whatever the operating system does with a deep link. A short life does not make that safe, but it bounds the window in which it matters, and 60 seconds is comfortably more than a redirect needs.

Token Lifetimes and Rotation

The lifetimes are where a design either takes revocation seriously or quietly does not. An access token that lives a week is a bearer secret with a week-long blast radius, and no amount of consent-screen design compensates for it.

Refresh rotation is the part worth implementing carefully. Every refresh issues a new refresh token and invalidates the old one. If an already-spent token is presented, that means two parties hold it, and there is no way to determine which one is calling. So neither is trusted: the whole chain is revoked and the user re-authorizes. This turns a silent compromise into a visible one, which is the best outcome available once a token has leaked.

Authorization code60 secondsRejected; already consumed.Travels through a browser URL. Worth almost nothing for almost no time.
Access token1 hourValid until expiry, bound to one audience.Short enough that revocation is meaningful, long enough to avoid refresh storms.
Refresh token30 daysWhole chain revoked.Rotates on every use, so age matters less than replay detection.
Lifetimes as implemented. The replay column is the one that matters: it is what separates rotation from a longer-lived secret with extra steps.

Audience Binding, the Part Most Servers Skip

RFC 8707 resource indicators are the least glamorous requirement and the one that changes the most about what a leaked token is worth. The client sends a resource parameter naming the MCP endpoint it wants a token for. The server records that as the token's audience. The resource server then rejects any token whose audience is not itself instead of accepting whatever is validly signed.

Skipping this is a common shortcut because everything appears to work without it. A signed token from your own authorization server validates fine, tools respond, the demo passes. What you have actually built is a general-purpose credential: a token minted for one resource is spendable at any other resource that trusts the same issuer. The check has to be explicit, because whether a token is genuine and whether it was issued for you are different questions, and only the second one is the access control decision.

For an MCP server the stakes are higher than for a typical API, because the token is held by an autonomous agent that will be pointed at many servers over its life. Confused-deputy problems are the normal case there rather than the exotic one.

What We Removed: The Anonymous Tier

The first version of this server had two tiers. Four of the five tools read public status data that the website already serves to anyone, so they were reachable with no credential at all, rate-limited per address, with the fifth tool gated behind a key. It was a reasonable-looking design and we shipped it. It is gone now, and the reasons are worth writing down because they generalise.

The first problem was code-shaped. Two tiers meant every layer needed to know which tier it was in: the tool registry partitioned its own list, the controller branched on credential presence, the rate limiter had a separate guard, and the discovery documents advertised a subset that had to be kept in sync with the partition. The authorization decision was spread across four places instead of made once, and every one of them was somewhere a future change could quietly disagree with the others.

The second was that an anonymous tier cannot be operated. There is no account to attribute usage to, so a heavy caller cannot be contacted, throttled individually, or told they are hitting a limit. Revocation is impossible because there is nothing to revoke. Per-account limits are impossible because there is no account. Address-keyed limits are the only lever available, and address-keyed limits are exactly the ones that punish a shared NAT and let a distributed caller through.

The third was that it was not buying anything. The data is public, but the website, the REST API, and the directory all already serve it. The anonymous MCP tier was a fourth door onto the same information, with its own authorization logic, for callers who could have used any of the other three. Removing it collapsed the decision to one place: every request carries a token, so every caller that reaches a tool is entitled to all of them, and there is no partition left to keep in sync.

The machinery did not get deleted, it moved. The address-hashing rate limiter that guarded the anonymous tier now guards registration and token exchange, which are the endpoints a stranger can still reach. That turned out to be the correct home for it all along.

Two Details That Cost Us Time

Statelessness is worth choosing deliberately. The MCP specification allows a session identifier negotiated on initialize and echoed on later calls. We do not use one: the server is stateless, so there is no session round trip and no server-side session table to expire, replicate, or leak. Clients that expect a session header work anyway, because its absence is valid. The cost is that any per-connection state would have to be reconstructed per call, and for a read-only tool surface there is none, so the trade is free. It will not be free for a server with stateful tools, so check that before you copy the choice.

The second is 403 versus 401 for a lapsed plan, which sounds like bikeshedding and is not. A caller whose subscription ended is authenticated; their identity is fine and their entitlement is not. Returning 401 tells a well-behaved client that its token is bad, and a well-behaved client responds by discarding a perfectly good token and running the whole authorization flow again, which succeeds, and then fails the same way on the next call. You have built an infinite loop out of two components that are each behaving correctly. A 403 stops it at the first call and surfaces a message a person can act on.

FAQ: Remote MCP Servers and OAuth

Do I have to implement a full authorization server? Not necessarily. The MCP specification lets a resource server delegate to an existing authorization server, and the protected resource metadata document is where you name it. We run our own because PulsAPI already had scoped API keys and a session model to reconcile with, and splitting that across two identity systems would have been the larger cost. If you have no such history, delegating is usually less work and less risk.

Is unauthenticated dynamic client registration safe? On its own, yes, provided a registration grants nothing until a person approves it. The risks are table growth and redirect abuse, and both are bounded at the endpoint: cap the redirect URIs, validate each one, narrow the requested scope to what you actually offer, and rate-limit registration separately and more tightly than token exchange.

Why is PKCE mandatory if there is a client secret? There is not one. Every MCP client is a public client, shipping its code to users, so a secret in it is not a secret. PKCE is not a supplement to client authentication here, it is the entire binding between the authorization request and the token exchange.

What breaks if I skip resource indicators? Nothing visible, which is the danger. Tokens validate and tools respond. What you have shipped is a credential that any other resource trusting the same issuer will also accept, so a token leaked from one integration is spendable against every other one. The audience check has to be explicit.

Should a remote MCP server expose write tools? Only with a deliberate, separate scope and a consent screen that names it in plain language. The asymmetry is the whole argument: a wrong read produces a wrong answer that a person reviews before acting, while a wrong write is already in production by the time anyone reads it. Our five tools are read-only for that reason, as covered in the announcement.

How do I test the discovery chain? Send an unauthenticated POST and read the WWW-Authenticate header, then follow each document by hand with curl before pointing a client at it. Most connection failures we saw were a metadata document that was missing, served at the bare well-known path instead of the path-suffixed one, or carrying an issuer that did not match itself character for character.

About the Author

J
James OkaforCo-founder & CTO

James is the co-founder and CTO of PulsAPI. He has spent over a decade building distributed systems and reliability tooling at fintech, payments, and developer-platform companies.

Start monitoring your stack

Aggregate real-time operational data from every service your stack depends on into a single dashboard. Free for up to 5 services.

Create Free Dashboard
Remote MCP Server OAuth 2.1: A Build Log