DEV Community

Cover image for Making your MCP server its own OAuth 2.1 server: 5 things that silently break clients
GoVideo
GoVideo

Posted on

Making your MCP server its own OAuth 2.1 server: 5 things that silently break clients

Every MCP server tutorial ends the same way: paste an API key into a JSON config file. That works until you want other people's agents talking to your API, at which point you're asking strangers to store your credentials in plaintext on their laptop.

The alternative is for your server to become an OAuth 2.1 authorization server, so the client opens a browser tab, the user signs in, and the agent gets a scoped token. claude mcp add --transport http yourserver https://api.example.com/mcp and nothing else.

We did this for GoVideo, a video platform where agents upload, encode and publish video. The spec is clear about what to build. It is much less clear about the handful of details that make clients fail with no useful error. Here are the five that cost us the most time.

  1. Your issuer must not have a path

If your issuer is https://api.example.com/mcp, there are two legal places a client may look for your metadata:

https://api.example.com/.well-known/oauth-authorization-server/mcp
https://api.example.com/mcp/.well-known/oauth-authorization-server

Both are correct per spec. Clients disagree about which to try. Some try one, give up, and report "server does not support OAuth."

Use a path-less issuer — https://api.example.com — and there is exactly one metadata URL. This single decision removes an entire category of client-compatibility bug.

  1. resource and aud are not the same string

This one is counter-intuitive. The resource indicator the client sends identifies the MCP endpoint:

resource=https://api.example.com/mcp

But the aud claim in the token you mint stays the bare origin:

{ "aud": "https://api.example.com" }

Make them the same and you will get token-validation failures that look like a signing problem and aren't.

  1. If you sign with HS256, publish an empty JWKS

You still need a jwks_uri — clients fetch it. But with a symmetric algorithm, the signing key is the verification key. Publish it and anyone can mint tokens that pass your own validation.

Return this:

{ "keys": [] }

An empty key set is a valid response. The endpoint exists so discovery succeeds; it just has nothing to give. If that feels wrong, that's the correct instinct — it's a sign you should be on RS256 with a real public key. Empty JWKS is the right answer only when your tokens are symmetric and validated solely by you.

  1. Open registration is safe if — and only if — you restrict redirect URIs to loopback

Dynamic Client Registration means any client can register itself without a human approving it. That sounds alarming, and it would be, except for one constraint: accept loopback redirect URIs only.

http://127.0.0.1:53174/callback

https://attacker.example.com/cb

An attacker registering a client gains nothing, because the authorization code can only be delivered to a browser on the victim's own machine. This is what makes open DCR safe, and it is the reason to resist "just allow this one https URI for now."

If you do need an exception — a hosted client, or an agent that deep-links to itself — allow two forms and no more:

  • https URLs, matched exactly, from a configured allowlist
  • private-use schemes like cursor://callback (RFC 8252 §7.1), where the agent deep-links to itself instead of listening on a port

Plain http with a real hostname should be refused outright, and we fail the boot if one appears in config. HTTP is defensible only for loopback — which is already approved without an allowlist.

  1. Return OAuth error bodies, not your API's error format

If your API uses RFC 7807 application/problem+json, your OAuth endpoints must not. Clients parse the OAuth error shape:

{ "error": "invalid_redirect_uri", "error_description": "..." }

Hand them a problem+json body and they'll report a generic connection failure, because they never got to the field explaining what went wrong.

A bonus that cost an afternoon

Not a spec issue — an environment one. Our allowlist is a single env var:

OAUTH_ALLOWED_REDIRECT_URIS=https://a.example.com/cb,https://b.example.com/cb

Written across multiple lines, set -a; . ./.env ends the value at the first newline. The first entry loads; the rest are executed as shell commands. Everything boots, and one client gets invalid_redirect_uri forever with no clue why.

Keep it on one line. And if a URI you approved in config still gets rejected, check whether a stale local .env is overriding that config wholesale — that was our actual bug, twice.

Was it worth it?

Yes, and by a wide margin. claude mcp add --transport http govideo https://api.govideo.stream/mcp opens a browser tab, the user signs in, done. No key generation, no config file editing, no credential sitting in plaintext, and revocation is a real thing that works.

The spec work is a couple of days. The five items above are most of what will actually cost you time.
If you're building an MCP server that touches user data, I'd make the same call again. Happy to answer questions about the implementation — we're on Spring Boot, but none of the above is framework-specific.

Top comments (0)