Third-party sign-in for a remote MCP server
Your users already have accounts on a platform whose API knows a username, a password and a session cookie — and nothing about OAuth. Claude’s custom connectors speak OAuth 2.1 and nothing else. This is the bridge: an authorization server inside the MCP server that signs people in with the platform’s own login, keeps the session it gets back, and never stores a password.
The brief
The server exposes a B2B platform’s admin API as MCP tools, so the people who already work in that platform’s web console can investigate their own account from Claude. Locally it runs on the workstation, with the credentials kept there. Remotely it is a Claude custom connector on a host the platform’s owner runs — and that is where identity stops being a config value.
Three constraints shaped everything that follows:
- People act as themselves. No shared account. The upstream’s own records name the person, and what they can see is what the upstream lets them see.
- No identity provider. Not on the table — and it would not have helped: the only system that knows these users and their passwords is the platform itself.
- No stored passwords. A server that keeps platform passwords becomes the most interesting thing on the network to break into.
The platform adds one rule of its own, common in older admin consoles: one session per user. A new login ends the previous one, wherever it was. Hold on to that; it decides most of the design.
Why the usual answers don’t fit
Most MCP auth guides start from “you already have an identity provider”. Here is how the usual options fared against the brief.
Auth0, Keycloak, Entra ID, WorkOS. The standard answer, and the right one when the IdP owns the users. Here it doesn’t: you would sync users from the platform, and the platform would still want its own password before handing over a session. Two logins, one of them decorative.
Claude can send a fixed header on every request. An admin sets it once per organisation and everyone shares it, so it identifies a company, not a person — and at the time of writing it is a beta.
Fine for a demo. Then the server holds a platform password per account, every person has a second username that isn’t the platform’s, and the upstream’s audit trail names a robot.
The MCP TypeScript SDK v2 kept the resource-server half and moved the authorization server to @modelcontextprotocol/server-legacy: a frozen copy of v1, Express-only, no CIMD, deprecated, and the SDK’s own upgrade guide says to put the authorization server somewhere else.
better-auth’s MCP plugin gives you PKCE, DCR, CIMD and refresh tokens, and the SDK’s own v2 example uses it. But it accepts an outside sign-in only by minting a shadow user and a browser session of its own per person, and what this problem needs — one upstream session per user, revoking every token when it ends, attempt limits per username — sits outside its API.
Five endpoints, a sign-in page and a SQLite table of hashed tokens, on top of the SDK’s requireBearerAuth and metadata builders. About two thousand lines of TypeScript, most of them the part no library would have written for you anyway.
The shape of it
The trick is that there are two credentials, and they never meet. Claude holds tokens your server issued, which mean nothing to the upstream. Your server holds the session cookie the upstream issued, which Claude never sees. A token is only a pointer to a person; the person’s row holds the upstream session. Everything else on this page is about keeping those two in step.
1 · Discovery: how Claude finds your sign-in
Claude starts with nothing but the URL someone pasted into the connector dialog. It finds the rest the way the MCP authorization spec says: call /mcp without a token, get a 401 with a pointer, follow the pointer.
The two documents in full. Nothing in them is exotic; the details are where it breaks.
// GET /.well-known/oauth-protected-resource/mcp (RFC 9728)
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://mcp.example.com"],
"scopes_supported": ["mcp"],
"bearer_methods_supported": ["header"]
}
// GET /.well-known/oauth-authorization-server (RFC 8414)
{
"issuer": "https://mcp.example.com",
"authorization_endpoint": "https://mcp.example.com/authorize",
"token_endpoint": "https://mcp.example.com/token",
"registration_endpoint": "https://mcp.example.com/register",
"revocation_endpoint": "https://mcp.example.com/revoke",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"client_id_metadata_document_supported": true,
"authorization_response_iss_parameter_supported": true,
"scopes_supported": ["mcp"]
}- The
resourcemust equal the URL the user typed, path included. Compare canonical forms: lower-case scheme and host, no default port, no trailing slash, no fragment. - Claude chooses CIMD only when both flags are there:
client_id_metadata_document_supported: trueand"none"intoken_endpoint_auth_methods_supported. Miss one and it falls back to dynamic registration without telling you why. - A request with no token gets no error code (RFC 6750 §3.1), only
WWW-Authenticate: Bearer resource_metadata="…". And it has to be a401: Claude ignores that header on a200. - Mount
/authorize,/tokenand/registerat the origin root. When discovery fails, Claude falls back to treating your origin as the authorization server. At the root, that failure still lands somewhere that works.
2 · Who is asking: CIMD, DCR, or an id you hand out
Before anyone signs in, the server has to know which client is asking and where it may send the code back. Claude’s connector dialog offers three ways, and a server that wants to work with more than one MCP client supports all three. Every client is public: no secret, and PKCE on every authorization instead.
- CIMD · client metadata document
- “Use Claude’s published identity”. The client_id is an https URL; your server fetches it and reads the client’s redirect URIs. Claude’s is https://claude.ai/oauth/mcp-oauth-client-metadata. Nothing to register, nothing to keep but a cache.
- DCR · dynamic registration (RFC 7591)
- “Register automatically”. The client posts its redirect URIs to /register and gets an id back. Deprecated in the 2026-07-28 revision of the MCP spec in favour of CIMD, but still Claude's fallback. Claude registers on every Connect (twice, in practice), so expect a steady trickle of rows and sweep the unused ones.
- Pre-registered id
- “Use your own OAuth client”. An admin types an id you configured. Useful when an organisation wants one fixed, auditable client.
CIMD is the recommended path, and the one with a sharp edge: the client tells your server which URL to fetch, which is the textbook definition of server-side request forgery. Fetch it like you mean it:
- https only, and the document’s own
client_idmust equal the URL it came from; - resolve the host yourself and refuse loopback, private, link-local and the other special ranges — for every address it resolves to;
- no redirects, a 5 KB cap, a 5-second timeout; cache by
Cache-Control, within bounds you choose; - on the sign-in page, show the host of the
client_idURL, never the name the document gives itself. Anyone can publish a document that says “Claude”.
Node’s net.BlockList has a trap here, found the first time the server fetched the real document:
// A BlockList rule for ::ffff:0:0/96 also matches every plain IPv4
// address, so it refused claude.ai itself. Unwrap mapped addresses
// (::ffff:10.0.0.1, and the hex form ::ffff:a00:1) and check them as IPv4.
const ipv4 = mappedIpv4(address) ?? (isIPv4(address) ? address : undefined);
const refused = ipv4 !== undefined
? blocked.check(ipv4, 'ipv4')
: blocked.check(address, 'ipv6');3 · The sign-in page: forward once, then forget
This is the one page in the flow you write yourself, and the one where people type a password for another system. The server sends those credentials to the upstream’s login once, asks who the person is and which account they belong to, stores the session cookie it got back, and redirects to the client with an authorization code. The password does not outlive the request.
- The password lives in one handler’s variables, for one request. Not stored, not logged — logger redaction on
passwordpaths is the second fence, not the first. - Two-factor: take the code, never the secret. If the upstream wants a TOTP code, the page asks for the six digits the person’s authenticator shows, exactly as the platform’s own console does.
- Turn refusals into sentences. Wrong password, code needed, code wrong, account blocked. A page that says “Error” is a support ticket.
- Limit attempts per username and per address, below the upstream’s own lockout, so a guessing script runs out of tries on your page before it locks the person out of the platform.
- Bind the form to its browser with a short-lived
__Host-cookie whose hash is stored with the sign-in request. - Never framed:
X-Frame-Options: DENYand a CSP ofdefault-src 'none'; frame-ancestors 'none'. - Warn before you take over. With one session per user, signing in here logs the person out of the platform’s console. Say so on the page, before it happens.
The account check comes after the login, because the account is only known once the upstream has accepted the password. That has a cost worth stating: a person whose account this server does not serve has still spent a login — and, on a one-session platform, their console tab.
4 · Tokens: opaque, hashed, rotated
The authorization server and the resource server are one process sharing one database, so JWTs buy nothing: no other service needs to verify a token without asking. Opaque random tokens, stored only as their SHA-256, make verification one indexed lookup and revocation a DELETE.
// Opaque, random, and only ever stored as a hash.
const token = randomBytes(32).toString('base64url');
db.prepare(
'INSERT INTO oauth_token (token_sha256, kind, grant_id, user_id, expires_at) ' +
'VALUES (?, ?, ?, ?, ?)',
).run(sha256(token), 'refresh', grantId, userId, now + 30 * DAY);
// Refresh: spend the presented token. If it was already spent,
// someone else holds a copy, so the whole grant goes.
const spent = tokens.spendRefresh(presented);
if (spent.outcome === 'replayed') tokens.revokeGrant(spent.record.grantId);
if (spent.outcome !== 'spent') return oauthError(400, 'invalid_grant');- Access tokens last an hour, refresh tokens thirty days, and every refresh rotates. Claude refreshes on a
401, and up to five minutes before expiry. - A second use of a refresh token or an authorization code revokes the whole grant. One of the two presenters is not the client.
- Every refresh asks the upstream whether the session still lives, with the cheapest authenticated call it offers. An ended session answers
invalid_grantat the refresh, before anyone asks a question. /tokenreadsapplication/x-www-form-urlencoded, which is what Claude sends;/registerreads JSON. A framework that parses JSON only answers415, and the connection fails without much of a clue.- The cheap extras: RFC 7009 revocation, and
issin the authorization response (RFC 9207), which the 2026-07-28 revision of the MCP spec recommends.
5 · Every tool call: token → person → upstream session
On /mcp, the SDK’s requireBearerAuth does the HTTP half — parse the header, check expiry and audience, answer with the right challenge — and calls your verifier for the rest:
import { OAuthError, OAuthErrorCode, requireBearerAuth } from '@modelcontextprotocol/server';
const gate = requireBearerAuth({
resourceMetadataUrl, // where the RFC 9728 document lives
expectedResource: new URL(mcpUrl), // tokens are bound to /mcp
verifier: {
async verifyAccessToken(token) {
const record = tokens.find(sha256(token)); // a hash lookup
if (!record || !sessions.holds(record.userId)) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Sign in again');
}
return {
token,
clientId: record.clientId,
scopes: ['mcp'],
expiresAt: Math.floor(record.expiresAt / 1000),
extra: { userId: record.userId }, // → per-request context
};
},
},
});The verified AuthInfo reaches the MCP server factory, which builds a per-request context: the person, their account, a logger bound to both, and a session object that sends upstream calls with that person’s cookie and applies every Set-Cookie it gets back, so a sliding expiry keeps sliding.
One upstream session per person, shared by all their connections — every chat, every device. On a one-session platform that is not a design choice: two sessions for the same person would simply keep ending each other.
6 · When the upstream ends the session
Upstream sessions end on their own schedule: an idle timeout, a password change, or — on a one-session platform — the same person logging into the web console. Your tokens know none of this. Left alone, Claude keeps presenting a valid access token that points at a dead session, and every tool call fails politely.
So the upstream’s 401 is the source of truth, and the server acts on it in the request that finds it:
Races, and the generation counter
Two connections of one person; one signs in again while the other’s call is in flight. The in-flight call gets a 401 from the upstream — but the session did not end, it was replaced. A generation number on the session row tells the two apart: a 401 under an older generation is retried once with the current session, and only a 401 under the current one ends anything. The end itself is conditional, so a late failure cannot undo a fresh sign-in:
-- End the session only if it is still the one this call used.
-- A sign-in that replaced it meanwhile has bumped the generation.
UPDATE user_session
SET cookie = NULL, generation = generation + 1, ended_at = :now
WHERE user_id = :user AND generation = :seen;
-- ...and in the same transaction, only if a row was updated:
DELETE FROM oauth_token WHERE user_id = :user;A 403 is ambiguous. It can mean the session is gone, or that this person’s role cannot read that route. Ask a route everyone can read, with the same cookie, and end the session only if that fails too. Otherwise a permissions gap logs people out.
Design decisions & honest trade-offs
SDK v2 still ships the resource-server side (requireBearerAuth, the RFC 9728 metadata builders); only the authorization server went to the legacy package. Writing that half means owning login, CSRF, rate limits and the SSRF guard — the part a frozen package is least safe to borrow. Budgeted at four to five working days with tests and a live run.
One process issues and verifies, so a lookup is cheaper than key management, and revoking a person is one DELETE that holds from the next request on. A JWT would stay valid until it expired, whatever the upstream thought.
Every refresh asks it; every 401 from it ends the session and the tokens with it, in one transaction. No token outlives the session it stands for, and nothing here second-guesses the platform’s idea of who is signed in.
It matches the platform’s rule instead of fighting it. The cost is real and visible: signing in through Claude ends the person’s console tab. The page says so before it happens.
A test runs the whole flow with logging at trace level, then scans the log, every stored row and every HTTP response. The password may appear in one place only: the body sent to the upstream’s login. Even the attempt counters are keyed on hashes, so no username is stored either.
No emailed one-time-code sign-in (the upstream offers one; this page takes a password), no SSO, no per-tool consent: one scope. The upstream cookie is the one secret kept in usable form; it is scoped, it expires, and its database file is readable by the service user only.
Field notes: what broke, so yours doesn’t
Each of these cost somewhere between ten minutes and an afternoon. Roughly in that order, worst first.
- 1Reconnect needs a 401.
Claude re-authenticates on an HTTP
401only — not on anisErrortool result, not on aWWW-Authenticateheader attached to a200. If the upstream session dies mid-call, the HTTP answer to that call has to be the401. - 2CIMD needs two flags.
client_id_metadata_document_supported: trueand"none"intoken_endpoint_auth_methods_supported. With either missing, Claude quietly registers through DCR instead. - 3no-referrer breaks your own form.
With
Referrer-Policy: no-referrer, browsers post the sign-in form withOrigin: null, and an Origin check then refuses it.same-originkeeps the header and leaks nothing. - 4Localhost validation behind a tunnel.
The SDK’s Host and Origin checks are built for a server on localhost. Behind a tunnel or reverse proxy the browser’s form arrives with your public host: allow that host explicitly, and only it.
- 5net.BlockList and ::ffff:0:0/96.
The IPv4-mapped rule matches every IPv4 address, including the one you are trying to reach. Unwrap mapped addresses and check them as IPv4.
- 6Don’t rate-limit /register like a login.
Registrations arrive from Anthropic’s egress range (
160.79.104.0/21) for every user of every organisation, two per Connect. A per-address limit there throttles everyone at once. Guard against floods instead, and keep per-person limits for the sign-in form, which comes from the person’s own browser. - 7X-Forwarded-For has more than one author.
Take the entry your own proxy appended, counting from the right. Some cloud load balancers append their own address after the client’s, which quietly turns a per-address limit into one shared by everybody.
- 8resource is compared exactly.
Canonicalise before comparing, on
/authorizeand on/token. Claude sends the canonical form of the URL the user typed; the user typed whatever they liked. - 9Two body formats.
/tokenis form-urlencoded,/registeris JSON. One body parser for both answers415to one of them. - 10Discovery is cached.
Claude caches the discovery documents for about five minutes, keyed by URL. Change the metadata, then wait, before deciding the change didn’t work.
- 11Double-clicks happen.
A double click on Sign in posts the form twice, and the browser abandons the first. Pass the request’s abort signal into the upstream call, so the abandoned login stops instead of racing the second.
How it’s tested
Every case runs at the HTTP-app seam, in memory, against a stand-in upstream that behaves like the real one — one session per user, so a second login really does end the first, and refusals in the platform’s real shapes.
The MCP TypeScript SDK’s own OAuth client completes a connection by CIMD and by DCR, on both protocol revisions the server speaks (2025-11-25 and 2026-07-28).
PKCE mismatch, code replay, refresh rotation and reuse, revocation, a wrong resource, an expired sign-in page, a form posted from another browser.
A console login mid-conversation, idle expiry, a sign-in racing an in-flight call, a 403 that is a permission and one that isn’t.
The whole flow at trace-level logging, then a scan of the log, every stored row and every response.
Then a live run with the real Claude connector through a tunnel, against the platform’s sandbox: connect, wrong password, sign in, ask, log in to the console in another tab, ask again. That run found the 401 problem above, which no test had: the tests asserted the behaviour I had designed, not the behaviour Claude has.
Status
Delivered as part of a client project: verified end to end with Claude’s custom connector against the platform’s sandbox; deployment is in the client’s hands. The code is private; the design, the field notes and the references on this page are the reusable part. More MCP in Agent Commerce on AP2, and more OAuth, from the wallet’s side, in SD-JWT VC + OpenID4VC.
Stack
References & further reading
The specifications and first-party documents this design was checked against. All links open in a new tab.
Putting an MCP server in front of your own platform?
Remote MCP, OAuth, and an upstream that predates both is a combination that keeps coming up. If you are building it, I’m happy to compare notes — or build it with you.
Get in touch →