← Back to portfolio

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.

Client
Claude custom connector
Protocol
MCP auth · OAuth 2.1
Upstream
Password + session cookie
Passwords stored
0
Role
Solo — design, implementation, tests, live run with Claude
Status
Delivered — verified end to end with a Claude custom connector; the client deploys it
Built with
TypeScript · Hono · MCP TypeScript SDK v2 · node:sqlite
Source
Client work, private — the patterns are the shareable part

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.

Wrong owner
An external IdP

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.

One key per org
Static request headers

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.

Stores passwords
Config users + a service account

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.

Frozen
The SDK’s own authorization server

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.

Fights the shape
An embedded OAuth library

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.

Chosen
By hand, on the SDK’s resource-server helpers

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.

Claude, the MCP server with its own authorization server, and the upstream platformThe person’s browseryour sign-in pageClaudecustom connectorMCP clientOAuth 2.1 public clientopensYOUR MCP SERVER · ONE PROCESSAuthorization server/.well-known/oauth-*/authorize · sign-in page/token · /register · /revokebuilt by handResource server/mcp · the toolsrequireBearerAuth (SDK)SQLitetoken hashesone upstream session per personno passwordsUPSTREAM PLATFORMLoginusername + password(+ TOTP code)Admin APIcookie-authenticatedcredentials, oncePKCE · tokensBearer tokencredentials, oncesession cookiesession cookieClaude never seesthe upstream cookie;the upstream neversees your tokens.
One process plays both OAuth roles: the authorization server that issues tokens and the resource server that accepts them. The person's browser carries the credentials to the sign-in page once; the server forwards them to the upstream's login and keeps the session cookie it gets back. Claude only ever holds your tokens; the upstream only ever sees its own cookie.

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.

How Claude discovers the sign-in, from the first 401 to the sign-in pageClaudeYour MCP serverclient_id URLclaude.ai · CIMD document① POST /mcp — no token401 · WWW-Authenticate: Bearer resource_metadata=…② GET /.well-known/oauth-protected-resource/mcpresource = …/mcp · authorization_servers③ GET /.well-known/oauth-authorization-serverendpoints · S256 · CIMD supported · auth: none④ browser → /authorize?client_id=https://claude.ai/…⑤ GET the client_id URLhttps · public IP only · ≤ 5 KB · 5 s · no redirects{ client_name, redirect_uris }→ sign-in page
Discovery, as Claude performs it. The 401 carries the pointer to the protected-resource document; that names the authorization server; its metadata says PKCE S256, public clients, and CIMD. With CIMD, the client_id is a URL your server fetches — under an SSRF guard — to learn where the code may be sent back.

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 resource must 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: true and "none" in token_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 a 401: Claude ignores that header on a 200.
  • Mount /authorize, /token and /register at 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_id must 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_id URL, 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 sign-in: from the authorization request to the first tokensClaudeBrowserYour MCP serverUpstreamopensGET /authorize · PKCE · resourcestore request · set __Host- cookiesign-in pagePOST username · password · codeattempt limits: user · addresslogin · credentials, oncesession cookiewho am I? · cookieuser id · accountaccount allowed? store the sessionthe password is dropped here302 → callback · code · state · isscodePOST /token · code + code_verifieraccess + refresh token (opaque)
The authorization-code flow with PKCE, where your server's login step is a call to the upstream's own login. The credentials cross your server once; what stays is the upstream's session cookie, stored against the person. The code goes back through the browser, and Claude trades it — with its PKCE verifier — for opaque tokens.
An illustration of the sign-in pagehttps://mcp.your-platform.example/authorize?client_id=…YOUR PLATFORMSign in to connect Claudeclaude.ai is asking to use your account.You’ll go back to claude.ai afterwards.Usernamejane.doePassword••••••••••6-digit codethe code your authenticator app shows now482913Sign inSigning in ends a console tab open as the same user:the platform allows one session per user.Your password goes to Your Platform once. This server does not keep it.
An illustration of the page, rebranded. It names the client by the host of its client_id URL, says where you’ll be sent back, takes the code the authenticator shows, and warns about the takeover before it happens.
  • The password lives in one handler’s variables, for one request. Not stored, not logged — logger redaction on password paths 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: DENY and a CSP of default-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.

What the sign-in database holds, and what it never holdsoauth_tokensha256(token) · access | refreshgrant · person · resource · expiryrefresh: rotated, single useauthorization_codesha256(code) · PKCE challengeredirect · single use · minutessign-in requestsha256(id) · sha256(browser cookie)lives as long as the pageuser_sessionperson · accountupstream session cookiegeneration · signed in · endedthe one upstream secretsign_in_attemptsha256(username | address) · timepoints atNEVER STOREDpasswordTOTP secretusernameraw tokensraw codes
Every token, code and sign-in request is stored as its SHA-256; a leaked database file yields nothing that can be presented. Tokens point at a person, and the person's row holds the upstream session — the one secret kept in usable form. Passwords, TOTP secrets and raw tokens are never written anywhere, and attempt counters key on hashes.
// 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_grant at the refresh, before anyone asks a question.
  • /token reads application/x-www-form-urlencoded, which is what Claude sends; /register reads JSON. A framework that parses JSON only answers 415, and the connection fails without much of a clue.
  • The cheap extras: RFC 7009 revocation, and iss in 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:

When the upstream ends the session: from a failed tool call to ReconnectClaudeYour MCP serverUpstreamsame person logs in elsewhere→ old session endstools/call · Bearer tokenAPI call · session cookie401newer sign-in meanwhile? → retry onceotherwise, in one transaction:end the session · delete every tokenHTTP 401 · invalid_tokennot a tool error: Claude reconnects on a 401 onlyPOST /token · refresh_token400 · invalid_grantshows “Reconnect”sign in again → a new session
The upstream's 401 is the source of truth. A newer sign-in by the same person is retried, not treated as an ending. A real ending deletes the person's tokens in the same transaction, and the request that found it answers HTTP 401 — not a tool error — so Claude's refresh fails at once with invalid_grant and Claude shows Reconnect.

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

Own the issuing half, borrow the verifying half

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.

Opaque over JWT

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.

The upstream decides

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.

One session per person, not per connection

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.

No password, provably

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.

What it doesn’t do

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.

  1. 1
    Reconnect needs a 401.

    Claude re-authenticates on an HTTP 401 only — not on an isError tool result, not on a WWW-Authenticate header attached to a 200. If the upstream session dies mid-call, the HTTP answer to that call has to be the 401.

  2. 2
    CIMD needs two flags.

    client_id_metadata_document_supported: true and "none" in token_endpoint_auth_methods_supported. With either missing, Claude quietly registers through DCR instead.

  3. 3
    no-referrer breaks your own form.

    With Referrer-Policy: no-referrer, browsers post the sign-in form with Origin: null, and an Origin check then refuses it. same-origin keeps the header and leaks nothing.

  4. 4
    Localhost 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.

  5. 5
    net.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.

  6. 6
    Don’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.

  7. 7
    X-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.

  8. 8
    resource is compared exactly.

    Canonicalise before comparing, on /authorize and on /token. Claude sends the canonical form of the URL the user typed; the user typed whatever they liked.

  9. 9
    Two body formats.

    /token is form-urlencoded, /register is JSON. One body parser for both answers 415 to one of them.

  10. 10
    Discovery 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.

  11. 11
    Double-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.

Interop

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).

OAuth edges

PKCE mismatch, code replay, refresh rotation and reuse, revocation, a wrong resource, an expired sign-in page, a form posted from another browser.

Session edges

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 password test

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

TypeScriptNode 24HonoMCP TypeScript SDK v2Streamable HTTP · statelessOAuth 2.1PKCE · S256CIMDDCR · RFC 7591RFC 9728RFC 8414RFC 8707RFC 7009RFC 9207node:sqliteZod 4Vitest

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 →
© 2026 Ionel MercaBuilt with React, TypeScript & Tailwind