adding oauth to a remote mcp server

a remote mcp server where the only stored credential is an api key the ai never sees - with two ways to connect: sign in, or paste a key. the oauth 2.1 setup, and the security trade-offs.

oauth flow for a remote mcp server: an ai client holds an opaque token, the server resolves it to an encrypted-vault key, and calls the upstream api

the setup

a remote mcp server needs auth, and mcp clients - claude, chatgpt, cursor - speak oauth 2.1, not “paste a key into a config file”. the constraint: the only credential the server stores is an api key to the upstream product, and the ai model should never see it.

so the server is both the oauth authorization server and the resource server. it validates the credential, encrypts it, stores it server-side, and hands the client a short-lived token. every tool call resolves that token back to the stored key.

client --oauth--> my server --> token
client --bearer token--> my server --> decrypt key --> upstream api

the ai only ever holds an opaque access token; the key stays encrypted in a vault.

two ways to connect

both paths end in the same stored-key + token model - they differ only in how the key reaches the server:

  • paste a key - the user pastes an api key on the consent page. the fallback for headless / config-file clients.
  • sign in - the user signs in on the product’s own frontend (backed by an identity provider, e.g. azure ad b2c), which mints a dedicated, named key and hands it to the server over a cors fetch, carrying the user’s sign-in token as the credential. the user never handles a key by hand, and it never lands in a url.

the sign-in path is the one i’d point a normal user at; paste-a-key stays for the clients that can’t open a browser.

the building blocks

  • the mcp typescript sdk ships an oauth server (mcpAuthRouter). mount it and you get /authorize, /token, /register and the discovery metadata for free. you implement one interface: OAuthServerProvider.
  • dynamic client registration + pkce - so claude/chatgpt register themselves and connect by url alone, no pre-shared client id.
  • an encrypted vault - aes-256-gcm, with the encryption key in secret manager (not the db). i key each entry by sha256(env:key) so the same key maps to the same record - no user table needed.
  • for the sign-in path, a single-use pending{state} record links /authorize to the handoff, and the handoff verifies the user’s sign-in token - signature, audience, issuer, expiry, per environment - before it touches anything, so the endpoint isn’t an anonymous key sink.

pick a stack you can lock down: serverless functions + a document db work well, with deny-all storage rules so nothing but the backend ever touches the vault. the one i shipped runs on firebase functions + firestore - rules deny-all, only the admin sdk gets in.

the honest bit

the sign-in token proves a real user is present; it doesn’t yet prove the key handed over is theirs - there’s no key→owner lookup on the backend yet. it’s bounded by the single-use, short-lived state and fresh-per-connect keys, and fully closing it (binding the key to the token’s user id) is a backend fast-follow. worth naming rather than hiding.

what i’d tell you

if you’re adding auth to a remote mcp server: lean on the sdk’s oauth router, keep the credential server-side and encrypted, and offer a sign-in path so users never paste secrets by hand. expect the platform gotchas - cors, verifying sign-in tokens across environments, one deploy serving multiple hostnames - to be where the time goes, not the oauth itself.

the ai never seeing the key turned out to be the cleanest part of the whole thing.