Adding a remote MCP server to a Next.js app

The full OAuth wiring for a remote MCP server on Next.js: streamable HTTP, .well-known discovery, PKCE, and the attack PKCE does not stop.

By the Deoochform team


Most “add MCP to your app” writeups stop at stdio: a local process, a config file, a token you paste in. That works on your laptop and nowhere else. A remote MCP server, the kind Claude or ChatGPT connects to over HTTPS with a browser sign in, needs OAuth, and the interesting parts are the ones no tutorial covers.

This is how ours is wired. Next.js App Router, no framework beyond the official SDK, roughly 150 lines across six files. The MCP server is not a side feature here, it is the product surface, which forced us to get the auth story right rather than hand waving it with a pasted API key.

The shape of it

Four HTTP surfaces:

  1. POST /api/mcp, the MCP endpoint itself.
  2. GET /.well-known/oauth-protected-resource/api/mcp, which says who authorizes it.
  3. GET /.well-known/oauth-authorization-server, which lists the OAuth endpoints.
  4. /authorize, /token, /register, the OAuth endpoints themselves.

A client that has never seen your server walks all four in order, unprompted. That discovery chain is the whole reason someone can type a URL into Claude and get a browser sign in instead of a token prompt.

The MCP endpoint

The SDK ships a transport that speaks Web standard Request and Response, which is exactly what an App Router route handler deals in:

async function handle(request: Request) {
  const actor = await resolveActor(request);
  if (!actor) return unauthorized(request);

  const server = createMcpServer(actor);
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });

  await server.connect(transport);
  return transport.handleRequest(request);
}

export { handle as GET, handle as POST, handle as DELETE };

Two things are worth pausing on. sessionIdGenerator: undefined makes the server stateless. On Vercel or any serverless host, consecutive requests land on different instances, so there is nowhere for a session to live. Stateless is not a downgrade here, it is the only thing that works.

And the server is constructed per request, with the caller baked in, rather than as a module level singleton with the user threaded through each tool call. Every tool closes over the caller's identity, so there is no code path where a tool can read a row belonging to somebody else. It costs one object allocation per request and removes a whole category of bug.

Why your connector silently never connects

Browser based clients call your endpoint cross origin. The browser fires a preflight OPTIONS first, and if that fails the real request never happens. You see nothing in your logs, and the client says something unhelpful about being unable to connect.

const CORS_HEADERS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
  "Access-Control-Allow-Headers":
    "Content-Type, Authorization, mcp-session-id, mcp-protocol-version, Last-Event-ID",
  "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
};

Expose-Headers is the one people miss. Without it the browser hides the MCP protocol headers from the client even though your server sent them.

The 401 that starts the dance

An unauthenticated call must not just return 401. It has to say where to go, using WWW-Authenticate per RFC 9728:

const metadataUrl =
  `${origin}/.well-known/oauth-protected-resource/api/mcp`;

return Response.json(
  { error: "Unauthorized" },
  {
    status: 401,
    headers: { "WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"` },
  },
);

This single header is the trigger for the entire browser sign in flow. Return a bare 401 and the client concludes your server is broken rather than password protected.

Note the metadata path. RFC 9728 nests it under the resource's own path, so if you serve two MCP endpoints, each needs its own document at its own nested path. They are not interchangeable.

Being your own authorization server

You are probably already sitting on a session system, so you do not need a third party identity provider for this. The metadata document is a static JSON file plus three routes:

export async function GET() {
  return NextResponse.json({
    issuer: origin,
    authorization_endpoint: `${origin}/authorize`,
    token_endpoint: `${origin}/token`,
    registration_endpoint: `${origin}/register`,
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code"],
    code_challenge_methods_supported: ["S256"],
    token_endpoint_auth_methods_supported: ["none"],
  });
}

token_endpoint_auth_method: "none" is correct and not a shortcut. An MCP connector is a public client: it ships to end users and cannot keep a secret, so PKCE, not a client secret, is what proves the token exchange came from the same client that started the flow. If you also serve confidential clients, a Zapier integration for instance, add client_secret_basic and client_secret_post alongside it.

Dynamic client registration, honestly

RFC 7591 says a client can register itself. In practice, for a public client authenticated by PKCE, there is nothing meaningful to store. The endpoint mints an id and hands it back, and a clients table would be ceremony that nothing downstream consults.

Be careful what you conclude from that, though. “We do not register clients, PKCE covers it” is the sentence we would have written before thinking it through, and it is wrong.

The attack PKCE does not stop

PKCE binds an authorization code to whoever started the flow. The usual mental model is that this makes an unregistered redirect_uri safe, because a stolen code is useless without the verifier.

That model breaks when the attacker is the one who started the flow. They craft an /authorize link with their own redirect_uri and their own code_challenge, and send it to a signed in victim. The victim's browser follows it. The code is minted against the victim's session, redirects to the attacker's callback, and the attacker exchanges it with the verifier they chose. An access token for someone else's account, from one click. PKCE did its job perfectly and protected nobody, because the attacker held the verifier all along.

So the redirect cannot be automatic. GET /authorize renders a consent page, and the code is only minted by a POST from that page:

export async function POST(request: Request) {
  const origin = request.headers.get("origin");
  if (origin !== url.origin) {
    return NextResponse.json(
      { error: "invalid_request", error_description: "Cross origin approval refused." },
      { status: 403 },
    );
  }
  // re-read params, re-read the session, then mint
}

Two properties do the work. The approval is a step a crafted link cannot perform on the victim's behalf. And Origin is sent on every form POST and cannot be set by page script, so a cross site auto submitting form is not an approval either.

Some smaller details that turned out to matter. The consent page names the host the code is about to go to rather than the full URI, because the host is the part that actually bears on the decision and a long URI is something to skim past. Re read the session inside the POST instead of trusting a hidden field, since the session is the only thing that says whose account the code is for. And redirect with a 303, not a 307: approval arrives as a POST, 307 preserves the method, and the browser would POST the code to a callback that only answers GET. The client reports a bare “Bad Request” and it looks like the client's bug rather than yours.

A confidential client with a pre-registered redirect_uri can skip the prompt. There is no third party to consent to, because the caller could not have changed the destination.

Tokens and the caller

The token you issue can just be a row. Store a hash, never the token, and let one query get you validity and the caller's identity, role and plan at once. The connection then acts as that user with their permissions, which means your existing row level security applies to MCP traffic for free. No parallel permission model to keep in sync.

Capabilities are URL shaped

We originally planned one endpoint with a capability flag, so a listed directory connector could be restricted while our own stayed full featured. It does not work: an app directory registers its OAuth client against a base URL and then freezes it. Whatever a listed URL is allowed to do has to be settled before you list it.

So there are two endpoints, the same server behind different options. The publicly listed one cannot see or create payment fields at all, which means nothing built through the directory listing can collect money. Decide this before you submit anywhere.

Checklist

  • Stateless transport, sessionIdGenerator: undefined.
  • Server built per request with the caller baked in.
  • CORS on every response, including Expose-Headers.
  • 401 carries WWW-Authenticate with the resource metadata URL.
  • Protected-resource metadata nested under each endpoint's own path.
  • Public client, PKCE, no client secret.
  • A consent page on /authorize, approved by same origin POST. PKCE alone does not stop a crafted-link attack, because there the attacker holds the verifier.
  • 303 on the post approval redirect, not 307.
  • Token hashes in the database, row level security does the rest.
  • Decide capabilities per URL before you list anywhere.

None of this is much code. The hard part was working out which pieces of the OAuth spec actually apply to a public client that a user connects by pasting a URL, and which are ceremony.

Frequently asked questions

Does a remote MCP server need OAuth?
Yes, if a client is going to connect to it over HTTPS rather than spawn it locally. The stdio setup most tutorials describe authenticates with a token you paste into a config file, which works on one laptop. A remote server is reached by URL by someone whose account you have to identify, and the MCP spec's answer to that is OAuth with a browser sign in. You do not need a third party identity provider for it: if you already have a session system, the authorization server is a static metadata document plus three routes.
Why does my MCP connector fail to connect with nothing in the logs?
Almost always CORS. Browser based clients call your endpoint cross origin, so the browser sends a preflight OPTIONS request first, and if that fails the real request is never made. Nothing reaches your server, so nothing is logged, and the client reports only that it could not connect. The header people miss is Access-Control-Expose-Headers: without it the browser hides mcp-session-id and mcp-protocol-version from the client even though your server sent them.
What does the WWW-Authenticate header do in an MCP 401?
It is what starts the sign in flow. An unauthenticated call must return 401 with WWW-Authenticate: Bearer resource_metadata="..." pointing at your protected resource metadata document, per RFC 9728. Return a bare 401 and the client concludes your server is broken rather than password protected. Note that RFC 9728 nests that document under the resource's own path, so two MCP endpoints need two documents at two nested paths. They are not interchangeable.
Is PKCE enough to secure an MCP authorization server?
No, and assuming it is leaves a one click account takeover. PKCE binds an authorization code to whoever began the flow, which protects nobody when the attacker is the one who began it: they craft an /authorize link carrying their own redirect_uri and code_challenge, send it to a signed in victim, and exchange the resulting code with the verifier they chose. The fix is that the redirect cannot be automatic. GET /authorize renders a consent page and the code is only minted by a same origin POST from it, because a crafted link cannot click Approve and Origin cannot be forged by page script.
Do I need dynamic client registration?
You need the endpoint, because clients call it, but for a public client authenticated by PKCE there is nothing meaningful to persist. Ours mints an id and hands it back without a clients table, since nothing downstream consults one. Do not read that as PKCE being sufficient on its own, though. Skipping the stored client is fine; skipping the consent step is the mistake.

Further reading