9 min read

Your MCP server works with a hard-coded token. Then you try it from Cursor, ChatGPT, a desktop app, and the client refuses to connect with no reason you can act on. A strict client wants an OAuth 2.1 authorization server behind your endpoint: metadata in a URL form you may not have seen, open client registration, PKCE with S256, a consent screen, and a token it can refresh. RFC 9728, 8414 and 7591 describe all of it, and none of them tells you which part your client is tripping on. This post is the sequence a strict client runs, what Rekey answers at each step, and the one file your own server needs.

MCP clientCursor, ChatGPT, a desktop appYour MCP servertools.example.comRekey/api/v1/mcp/your-app1. POST /mcp with no token. 401 + resource_metadata2. GET your /.well-known/oauth-protected-resource3. GET authorization-server metadata, either URL form4. POST /oauth/register. Gets a client_id, no secret5. /oauth/authorize in a browser. Sign in, consent, code6. POST /oauth/token with the code and code_verifier7. POST /mcp with Authorization: Bearer8. POST /oauth/introspect with your secret key
Steps 3 to 6 and 8 land on Rekey. Your server answers steps 1, 2 and 7, and never sees a password.

Why the client refuses, and says nothing

An MCP client does not fail on the first call. It fails on whichever of eight calls comes back wrong, and most clients report that as a generic connection error. The usual misses: the 401 carries no WWW-Authenticate header, so the client has no idea where the authorization server is; the metadata is served only as issuer/.well-known/... while the client constructs origin/.well-known/.../issuer-path; there is no registration endpoint, so a client that has never seen your server cannot get a client_id; or PKCE is optional on your side and the client sends S256 expecting it to be checked. Each is a few lines to fix once you know which one it is.

With Rekey, MCP is a switch on the Application in the panel, under Authentication. Turn it on and the authorization server exists at https://api.rekey.dev/api/v1/mcp/your-app. If you have no Application yet, sign up and create one first. Your server keeps its own code and adds two things: a metadata document that points at that issuer, and a token check on each call. The MCP servers page covers the billing half; this post is the sign-in half in detail.

The eight calls, with what Rekey answers

The examples use an Application with the slug your-app on Rekey Cloud. Self-hosted, replace the host with your PUBLIC_WEBHOOK_BASE_URL. Every URL below comes from the server's route table, so a client gets exactly these.

1. The challenge. The client POSTs to your MCP endpoint with no token. You answer 401 with WWW-Authenticate: Bearer resource_metadata="https://tools.example.com/.well-known/oauth-protected-resource". This header is the whole reason the client can find anything else. Rekey's own hosted MCP endpoint sends the same header on an unauthenticated call.

2. Your protected-resource metadata (RFC 9728). The client fetches the URL from the header. You serve a small JSON document whose resource is your MCP endpoint and whose authorization_servers names the Rekey issuer:

{
  "resource": "https://tools.example.com/mcp",
  "authorization_servers": ["https://api.rekey.dev/api/v1/mcp/your-app"],
  "scopes_supported": ["mcp:account"],
  "bearer_methods_supported": ["header"]
}

3. Authorization-server metadata (RFC 8414). The client takes the issuer and fetches its metadata. Some clients append /.well-known/oauth-authorization-server to the issuer. Strict ones insert the well-known segment after the origin and put the issuer's path after it. Rekey serves the same body at both:

https://api.rekey.dev/api/v1/mcp/your-app/.well-known/oauth-authorization-server
https://api.rekey.dev/.well-known/oauth-authorization-server/api/v1/mcp/your-app

For an Application with MCP on and nothing else, the body is:

{
  "issuer": "https://api.rekey.dev/api/v1/mcp/your-app",
  "authorization_endpoint": "https://api.rekey.dev/api/v1/mcp/your-app/oauth/authorize",
  "token_endpoint": "https://api.rekey.dev/api/v1/mcp/your-app/oauth/token",
  "registration_endpoint": "https://api.rekey.dev/api/v1/mcp/your-app/oauth/register",
  "introspection_endpoint": "https://api.rekey.dev/api/v1/mcp/your-app/oauth/introspect",
  "scopes_supported": ["mcp:account"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}

4. Dynamic client registration (RFC 7591). The client POSTs its redirect_uris and a client_name to /oauth/register with no credential, and gets 201 with a client_id, token_endpoint_auth_method: "none" and no secret. Every client is a public client; PKCE stands in for the secret. A redirect URI must be https, an http loopback address, or a custom scheme, otherwise the answer is 400 INVALID_REDIRECT_URI. Registration is open by default and rate limited.

5. Authorize, in a browser. The client opens /oauth/authorize with response_type=code, its client_id, a registered redirect_uri, a code_challenge with code_challenge_method=S256, and usually scope=mcp:account and a state. Rekey checks the client and redirect URI before it shows anything, renders a sign-in and consent page in your Application's name and colours, runs MFA when your policy requires it, and redirects back with a code that lives 60 seconds and works once. An Application with a hosted authorize page hands the sign-in to that page instead.

6. Token. The client POSTs a form-encoded body to /oauth/token: grant_type=authorization_code, the code, its code_verifier, the same redirect_uri and client_id. The answer is access_token, token_type: Bearer, expires_in, refresh_token and scope. The access token lasts an hour. The refresh token is redeemed at the same endpoint with grant_type=refresh_token and the client_id, and rotates on every use. A wrong verifier or a replayed code gets 400 invalid_grant.

7. The call. The client repeats step 1 with Authorization: Bearer. From here on, every tool call carries the token.

8. Your server introspects (RFC 7662). You POST the token to /oauth/introspect with your Application's secret key as the bearer. The publishable key is refused, because the answer reveals token state, and a key from another Application gets 401 invalid_client. The answer is never cached on Rekey's side and comes with Cache-Control: no-store.

curl -X POST https://api.rekey.dev/api/v1/mcp/your-app/oauth/introspect \
  -H "Authorization: Bearer $REKEY_SECRET" \
  --data-urlencode "token=$ACCESS_TOKEN"
{
  "active": true,
  "sub": "cmh3k2q9x0001ab9f7t5e1xyz",
  "scope": "mcp:account",
  "aud": "https://api.rekey.dev/api/v1/mcp/your-app",
  "exp": 1761300000,
  "iat": 1761296400,
  "token_type": "Bearer"
}

sub is the end user's id in your Application, the same id your sign-in flows and webhooks use. oid appears when the grant acts for an organization. A token Rekey has stopped honouring reads active: false on the next call: the user was erased, signed out everywhere, changed their password, or the organization binding no longer holds.

A tiny MCP server that checks the token

This is steps 1, 2, 7 and 8 in one Express file, with @rekey.dev/node doing the introspection call. rekey.mcp.introspect() looks up your Application's slug once from the secret key, then posts to the introspection route above. The Rekey side is identical from any framework.

import express from "express";
import { Rekey } from "@rekey.dev/node";

const rekey = new Rekey({
  apiUrl: process.env.REKEY_URL!,       // https://api.rekey.dev
  secretKey: process.env.REKEY_SECRET!, // this Application's secret key
});

const SELF = "https://tools.example.com";
const ISSUER = "https://api.rekey.dev/api/v1/mcp/your-app";
const RESOURCE_METADATA = `${SELF}/.well-known/oauth-protected-resource`;

const app = express();
app.use(express.json());

// Step 2: the client fetches this after your 401.
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
  res.json({
    resource: `${SELF}/mcp`,
    authorization_servers: [ISSUER],
    scopes_supported: ["mcp:account"],
    bearer_methods_supported: ["header"],
  });
});

// Step 8: ask Rekey whether the bearer token is live.
async function caller(authorization = "") {
  if (!authorization.startsWith("Bearer ")) return null;
  const token = authorization.slice(7);
  const result = await rekey.mcp.introspect(token);
  if (!result.active) return null;
  if (!(result.scope ?? "").split(" ").includes("mcp:account")) return null;
  return { endUserId: result.sub!, organizationId: result.oid ?? null };
}

app.post("/mcp", async (req, res) => {
  const who = await caller(req.headers.authorization);
  if (!who) {
    // Step 1: the challenge that starts the whole flow.
    res
      .status(401)
      .set("WWW-Authenticate", `Bearer resource_metadata="${RESOURCE_METADATA}"`)
      .end();
    return;
  }
  // who.endUserId is the signed-in user. who.organizationId is the
  // organization the grant acts for, or null for a personal grant.
  // ...answer the JSON-RPC request as your server already does
});

app.listen(8080);

Two details are easy to drop. The scope check is yours to make: an Application that is also an OpenID Provider issues tokens with openid alone, and introspection reports them active: true, so your server has to insist on mcp:account the way Rekey's own endpoint does. And introspection is a network call on every request. It is counted against your secret key's own budget, 30000 a minute by default (RATE_LIMIT_API_KEY_MAX), so one server address serving every user is fine. If your volume gets near that, hold an active: true answer in memory for a few seconds, keyed by a hash of the token and never past its exp, knowing that every second you cache is a second a revoked token keeps working.

You may not need your own server at all

If what the agent needs is the user's own account, Rekey already hosts an MCP server per Application at https://api.rekey.dev/api/v1/mcp/your-app, with five read-only tools: profile, subscription, credits, licences and devices. Switch it on, paste the URL into the client, and the same OAuth flow runs with no code on your side. The MCP guide has the client-by-client setup.

Scopes, and who the token acts for

An MCP-only Application supports exactly one scope, mcp:account, which means reading the signed-in user's own data. When the Application also has OpenID Connect switched on, openid and profile join the list, and email only if the Application requires verified addresses. The grant is the intersection of what was asked and what is supported. A request that names no scope at all gets mcp:account, which is what older MCP clients rely on. A request that names only scopes this Application cannot grant is sent back with error=invalid_scope before any sign-in form is shown, so a client cannot talk its way into a token that covers something else.

When your Application has organizations and the user belongs to one, the consent page adds a choice: act for the personal account, or for one of their organizations. The choice is made once and belongs to the grant. It rides in the authorization code, across every refresh, and in the access token, and introspection reports it as oid. Your server can scope its answers to that organization the way Rekey's own tools do. The binding fails closed: if the user leaves the organization, is removed, has their role disabled, or the organization is deleted, the access token introspects as active: false and the refresh grant answers 400 invalid_grant without rotating. It never falls back to the personal account, because a client told it acts for Acme would otherwise keep reporting one person's data as Acme's. Grants made before organizations were switched on stay personal. When to use organizations covers the model itself.

Three switches you will want

  • Sign everyone out. In the panel, under the Application's Settings, “Sign out all end-users” calls POST /api/v1/tenant/applications/:id/rotate-sessions. In one transaction it bumps the Application's token generation and revokes every refresh token, so every MCP access token introspects as active: false at once and no client can refresh its way back. Clients see a 401 with a fresh challenge and re-run the flow. It is irreversible and it signs out your web users too.
  • Close registration. Open registration is what lets a client connect on first contact, and it also lets anyone register a redirect URI of their choosing. Once the clients you expect have registered, set authConfig.dynamicClientRegistration to false on the Application's OAuth clients tab or with PATCH .../auth-config. The endpoint then answers 403 CLIENT_REGISTRATION_DISABLED, registration_endpoint leaves the metadata, and every client already registered keeps working.
  • Turn MCP off. With authConfig.mcpEnabled off, Rekey's hosted endpoint and its protected-resource metadata answer 404 MCP_NOT_FOUND and mcp:account stops being offered. The authorization server itself stays up only while the Application is also an OpenID Provider.

What the client sees when a step fails

Each refusal uses the code the spec names, at the place the spec names it, so a client that logs its errors can tell you which step broke.

AnswerWhat it means
404 MCP_NOT_FOUNDThe slug is wrong, or MCP is off for this Application.
400 INVALID_REDIRECT_URIA registered redirect URI is not https, an http loopback address, or a custom scheme.
403 CLIENT_REGISTRATION_DISABLEDRegistration was closed. The client needs a client_id the operator already has.
redirect with error=invalid_requestcode_challenge_method was not S256. PKCE is mandatory and plain is refused.
redirect with error=invalid_scopeThe client asked only for scopes this Application does not offer.
redirect with error=login_requiredThe client sent prompt=none. There is no silent sign-in; every authorization authenticates.
400 invalid_grantWrong code_verifier, a replayed code, an expired code, or a refresh token that is not this client's.
401 invalid_clientAt the token endpoint, an unknown client_id. At introspection, a missing or wrong secret key.
403 insufficient_scopeAt Rekey's hosted endpoint, a valid token that was not granted mcp:account.

What is not supported

Everything the metadata advertises is implemented, and what is not supported is advertised as unsupported and refused with the spec's own code. Before you build on it, these are the limits as they stand in 2.2.1:

  • The token's audience is the Rekey issuer, never your server's URL. A resource parameter (RFC 8707) on the authorization request is accepted and ignored. A token issued for your Application works at Rekey's hosted endpoint and at your server alike.
  • Tokens cannot be verified locally. They are signed with a key only Rekey holds, so introspection is the only check.
  • Public clients only. No client_secret, no private_key_jwt; PKCE with S256 is the client's proof, and plain is refused.
  • Authorization code only. No implicit or hybrid flow, no request or request_uri objects, no prompt=none. max_age is not enforced; auth_time is reported so the client can check it.
  • No consent persistence. Every authorization asks the user to sign in and consent again, and a hosted authorize page must show a consent screen before it mints a code. No RP-initiated or back-channel logout either.
  • No operator-side client registration. POST /oauth/register is the only way a client comes to exist, which is why it is open by default.
  • Rekey's hosted MCP endpoint speaks JSON-RPC over POST only. A GET gets 405 with Allow: POST; there is no server-to-client stream.
  • There is no starter template for an MCP server. The file above is the whole integration, wired into a server you already have.

The protocol tables, the hosted tools and the setup for each client are in the MCP guide. If the Application should also sign people into another product of yours, the same issuer serves OpenID Connect; see using an Application as an OpenID Connect provider. The source for every route named here is public in apps/api/src/modules/mcp.

Was this page helpful?

MCP server OAuth 2.1, no auth server to build | Rekey