MCP connectors
This is the case the package was built for: a user adds your API to Claude or ChatGPT as a custom connector, authorizes it, and the assistant calls your MCP server as them.
Three pieces have to be right — protect() on the API, the discovery chain from a cold 401, and audience binding. Get discovery wrong and the client cannot find your authorization server at all; get audience wrong and any token for any of your APIs opens all of them.
Mount protect()
app.use('/mcp', oauth.protect('contacts.read'), mcpRouter);That is the whole integration. protect() verifies the bearer token against the database, enforces the audience and the scopes, and sets req.oauth:
{
"userId": "…",
"clientId": "…",
"contextId": null,
"scopes": ["openid", "contacts.read"],
"grantId": "…",
"tokenId": "…",
"audience": ["https://api.example.com/mcp"]
}That shape is a fixed contract — the field set is asserted exactly in test/protect.test.ts, so adding a field is a deliberate change to a published interface. Your MCP tool handlers read req.oauth.userId and scope their queries to it.
Variants:
oauth.protect() // any valid token for the default resource
oauth.protect(['contacts.read', 'contacts.write']) // requires BOTH (mode: 'all', the default)
oauth.protect(['a', 'b'], { mode: 'any' }) // requires either
oauth.protect('reports.read', { resource: OTHER_RESOURCE })resource defaults to the first entry in config.resources. A typo in either the resource or a scope name throws at mount time, not on the first request — because the symptom otherwise is universal 401s that do not point at the guard that caused them.
Full reference: protect().
The discovery chain
An MCP client that has never heard of your server starts by calling your API with no credentials. Everything it needs to authorize has to be reachable from the 401 it gets back.
1. GET /mcp → 401
WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"
2. GET /.well-known/oauth-protected-resource/mcp (RFC 9728)
{ "resource": "https://api.example.com/mcp",
"authorization_servers": ["https://api.example.com"],
"scopes_supported": [...],
"bearer_methods_supported": ["header"] }
3. GET /.well-known/oauth-authorization-server (RFC 8414)
{ "issuer": "…", "authorization_endpoint": "…", "token_endpoint": "…", … }
4. → /authorize with PKCE and resource=https://api.example.com/mcpThe resource_metadata parameter on the challenge is the single most important line in protect(). Without it the chain is a dead end and the only remaining fix is out-of-band configuration. It is present on every challenge protect() emits, including the bare one for a request that carried no credentials at all.
The metadata URL is generated by the same function the discovery router registers its route from, so the two cannot drift. RFC 9728 §3.1 puts the well-known segment between the host and the resource's path, which is why https://api.example.com/mcp publishes at …/.well-known/oauth-protected-resource/mcp rather than at /mcp/.well-known/….
A resource whose id is a bare origin publishes at the unsuffixed path, and the unsuffixed path also serves the first configured resource — a host with one API never has to know suffixes exist.
This is why oauth.routes.discovery must be mounted at the origin root. It is the one router in the package whose mount path you do not get to choose.
When your issuer has a path
The same transformation applies to the issuer, and the two specs that describe it disagree about the direction. Both are served.
Your issuer | Document | Served at |
|---|---|---|
https://example.com | RFC 8414 | /.well-known/oauth-authorization-server |
https://example.com | OIDC Discovery | /.well-known/openid-configuration |
https://example.com/api | RFC 8414 §3.1 | /.well-known/oauth-authorization-server and /.well-known/oauth-authorization-server/api |
https://example.com/api | OIDC Discovery §4.1 | /.well-known/openid-configuration, /.well-known/openid-configuration/api, and /api/.well-known/openid-configuration |
RFC 8414 §3.1 inserts the well-known segment between the host and the issuer's path — the same rule as RFC 9728 above. OpenID Connect Discovery 1.0 §4.1 appends it to the issuer instead. RFC 8414 §5 names the disagreement and says a deployment may have to answer both, so this package answers both rather than picking a winner and being undiscoverable to half the clients. Every one of those paths returns the byte-identical document.
A path that is not your issuer is a 404 in JSON, not the document for a different server — /.well-known/oauth-authorization-server/other when your issuer is …/api answers {"error":"not_found", …} naming the real issuer.
Audience binding
MCP requires tokens to be bound to the resource they are for (RFC 8707), and protect() enforces that rather than merely recording it.
The client sends resource=https://api.example.com/mcp on /authorize. That value is validated against both your resources catalog and the client's allowedResources, stored on the authorization code, and copied onto the issued tokens as audience. protect() rejects a token whose audience does not include the resource it guards.
A token minted for https://api.example.com/reports is valid, unexpired, and correctly scoped — and still gets a 401 at /mcp. That is the confused-deputy defense: without it, every resource behind one issuer is a proxy for all the others.
The rejection is invalid_token, not insufficient_scope, and deliberately so. From this resource's point of view the token is not addressed to it at all, and saying "wrong audience" out loud would confirm to whoever stole it that the token is otherwise good.
A client that sends no resource gets bound to its first allowed resource rather than to nothing — an unbound access token is one a confused deputy can replay at any API you run.
Challenge shapes
| Situation | Status | WWW-Authenticate | Body error |
|---|---|---|---|
No Authorization header | 401 | Bearer resource_metadata="…" | invalid_request |
| Unknown / expired / revoked token | 401 | Bearer error="invalid_token", error_description="…", resource_metadata="…" | invalid_token |
| Token for another resource | 401 | as above | invalid_token |
| Missing scope | 403 | Bearer error="insufficient_scope", error_description="…", scope="contacts.write", resource_metadata="…" | insufficient_scope |
The bare challenge carries no error because nothing was wrong with what was sent — RFC 6750 §3.1. The scope parameter on the 403 is what lets a client fix itself by asking for more next time.
Header only. A token in ?access_token= is ignored and answered 401. Tokens in URLs land in access logs, Referer headers, and browser history, and the protected-resource metadata this package publishes says bearer_methods_supported: ["header"].
Vendor callback URLs
clients.create() compares redirectUris byte-for-byte, and the value has to be transcribed out of somebody else's documentation. That combination — "one wrong character" against "nothing downstream can rescue it" — is the most error-prone step in the entire setup, so the values ship as exported constants rather than as prose to retype.
import {
CLAUDE_CONNECTOR_REDIRECT_URI,
CLAUDE_CODE_REDIRECT_URIS,
CHATGPT_LEGACY_REDIRECT_URI,
CHATGPT_CONNECTOR_REDIRECT_URI_PATTERN,
CIMD_ALLOWED_HOSTS,
} from '@jeffjassky/oauth-host';| Export | Value | Notes |
|---|---|---|
CLAUDE_CONNECTOR_REDIRECT_URI | https://claude.ai/api/mcp/auth_callback | claude.ai's hosted connector. |
CLAUDE_CODE_REDIRECT_URIS | http://localhost/callback, http://127.0.0.1/callback | Register both, and without a port — see below. |
CHATGPT_LEGACY_REDIRECT_URI | https://chatgpt.com/connector_platform_oauth_redirect | The older single callback. Still accepted for existing connectors. |
CHATGPT_CONNECTOR_REDIRECT_URI_PATTERN | https://chatgpt.com/connector/oauth/{callback_id} | A shape, not a value. See below. |
CIMD_ALLOWED_HOSTS | claude.ai, chatgpt.com | A suggested clientIdMetadata.allowedHosts, never a default. |
The array exports are readonly, so they spread rather than assign: redirectUris: [CLAUDE_CONNECTOR_REDIRECT_URI, ...CLAUDE_CODE_REDIRECT_URIS], allowedHosts: [...CIMD_ALLOWED_HOSTS]. That is also how they get combined in practice.
These are vendor product details, not standards
Every value above can change without notice and without a release of this package. Each is stamped in src/server/vendors.ts with the date it was verified against vendor documentation — currently 2026-08-12. If a connector starts failing with redirect_uri is not registered for client …, check the vendor's current setup screen before assuming a bug here.
Claude Code's port varies
Claude Code registers a loopback callback and then connects from an ephemeral port it binds per session (http://127.0.0.1:54321/callback). RFC 8252 §7.3 requires an authorization server to allow that, and this package does: for a loopback URI — host exactly localhost, 127.0.0.1 or [::1] — the port is ignored and everything else is compared exactly.
So register http://127.0.0.1/callback, not http://127.0.0.1:3000/callback (either works, but the portless form says what is meant). Both hosts are registered because localhost and 127.0.0.1 are different registrations — the package does not resolve one to the other, because that would mean deciding what the client's resolver says.
Nothing else is relaxed. https://evil.test:8443/cb does not match a registration of https://evil.test/cb, and http://localhost.evil.test/cb is not loopback. A general "ports don't count" comparison is an open redirect, and there is a test file named after each of those failures.
ChatGPT's callback is per connector
ChatGPT's current callback is https://chatgpt.com/connector/oauth/{callback_id} — a pattern, where {callback_id} is assigned when the connector is created. There is no fixed string to ship, and this package deliberately does not invent one.
Copy the exact URL from your connector's setup screen and paste it into redirectUris. CHATGPT_CONNECTOR_REDIRECT_URI_PATTERN exists so the docs and the code agree on the shape; it is not a value to register, and registering it literally produces a client that never authorizes.
CHATGPT_LEGACY_REDIRECT_URI is the older single callback and is still accepted for connectors that were created against it.
Connecting Claude or ChatGPT
There is no dynamic client registration (RFC 7591) — see why. Three ways to get a connector registered instead.
Manually, the default
Register the connector once and paste the credentials into their setup UI.
import {
CLAUDE_CONNECTOR_REDIRECT_URI,
CLAUDE_CODE_REDIRECT_URIS,
} from '@jeffjassky/oauth-host';
const { clientId, clientSecret } = await oauth.clients.create({
name: 'Claude',
redirectUris: [CLAUDE_CONNECTOR_REDIRECT_URI, ...CLAUDE_CODE_REDIRECT_URIS],
allowedScopes: ['openid', 'profile', 'email', 'contacts.read'],
branding: { logoUrl: 'https://…/claude.png', publisher: 'Anthropic' },
});The secret is returned once. Store it wherever you paste it from; only its SHA-256 is kept here.
Then, in the client's custom-connector setup: your MCP server URL, the clientId, and the clientSecret. The client discovers everything else from the chain above.
Manually, as a public client
For a client that takes a client id and no secret. Codex CLI is the one that forces this: codex mcp login has oauth_client_id, oauth_resource and bearer_token_env_var, and no field for a secret at all. It publishes no metadata document either, so CIMD cannot serve it.
const { clientId } = await oauth.clients.create({
name: 'Codex CLI',
type: 'public', // no secret is generated
redirectUris: ['http://localhost/callback'], // loopback; the port may vary
allowedScopes: ['openid', 'contacts.read'],
})The registration is client_id plus PKCE, and PKCE is already mandatory here. There is no clientSecret in the return value to paste anywhere, and rotateSecret() on this client throws. type defaults to 'confidential', so this is opt-in per registration.
Public and CIMD are independent — this needs no clientIdMetadata config and makes no outbound request.
With a client ID metadata document
Both Claude and ChatGPT prefer this and will use it automatically when discovery advertises it — the user pastes your MCP server URL and nothing else. Enable it and name the hosts you trust:
import { CIMD_ALLOWED_HOSTS } from '@jeffjassky/oauth-host';
clientIdMetadata: {
enabled: true,
allowedHosts: [...CIMD_ALLOWED_HOSTS], // ['claude.ai', 'chatgpt.com']
}Their client_id is then an https:// URL serving a document that describes them, which this server fetches and treats as the registration. There is no secret to paste and nothing to provision per install.
It is off by default because it means your server makes an outbound request to a URL a request parameter chose. Read Client ID metadata before enabling it.
A checklist for the things that actually go wrong:
issueris the public origin. Nothttp://localhost:3000behind a proxy, not with a trailing slash, not the internal hostname. Clients compare it.- The callback URL is registered byte-for-byte. Exact string equality, no prefix matching, no ignoring the query string. Use the constants where there is one, and for ChatGPT copy the exact per-connector URL from its setup screen. Do not retype either. The single exception is the port of a loopback URI, which is allowed to vary.
httpseverywhere except loopback.clients.create()rejects anything else, which is a boot-time error rather than a mystery at authorization time./.well-known/*is reachable from the public internet, not behind the session middleware that guards the rest of your app.- The scopes you registered are the scopes the client asks for. A scope in the catalog but not in the client's
allowedScopesis a redirectedinvalid_scopeerror, naming the scope. A client that sends noscopeat all getsdefaultScopes— never the client'sallowedScopes, which is a registration ceiling — and a redirectedinvalid_scopeerror if you have not configured that key. - You mounted
discoveryat the root andoauthatmountPath. If the two disagree,token_endpointin the metadata points at a 404.
Confirm with two curls before involving a client at all:
curl https://api.example.com/.well-known/oauth-protected-resource
curl https://api.example.com/.well-known/oauth-authorization-server
curl -i https://api.example.com/mcp # expect 401 + WWW-AuthenticateWhat the client sees in metadata
{
"issuer": "https://auth.test",
"authorization_endpoint": "https://auth.test/oauth/authorize",
"token_endpoint": "https://auth.test/oauth/token",
"revocation_endpoint": "https://auth.test/oauth/revoke",
"userinfo_endpoint": "https://auth.test/oauth/userinfo",
"jwks_uri": "https://auth.test/oauth/jwks",
"scopes_supported": ["openid", "profile", "email", "contacts.read", "contacts.write"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["ES256"],
"authorization_response_iss_parameter_supported": true
}/.well-known/openid-configuration serves the identical object — for this server they are the same document, and generating both from one table is what stops them diverging into a state where you advertise a token endpoint you moved a year ago.
This is the baseline shape, with CIMD off. Turn it on and token_endpoint_auth_methods_supported gains "none" and "private_key_jwt", client_id_metadata_document_supported: true appears, and so does token_endpoint_auth_signing_alg_values_supported: ["RS256", "ES256"] — see private_key_jwt client assertions for what that last one enables. This jwks_uri, unrelated to CIMD, is this server's own signing-key endpoint (RFC 8414 §2) and is always present — do not confuse it with a client's jwks_uri in its own CIMD document, which is a different URL entirely and never appears in this server's own metadata.
authorization_response_iss_parameter_supported: true matters: advertising it is what lets a client require iss on the authorization response and so refuse a mix-up attack. Every authorization response this package emits carries it, errors included.
On offline_access: Claude appends that scope to its authorization request only when the server advertises it in scopes_supported, and this package issues a refresh token regardless of whether it was asked for — so nothing breaks either way and there is nothing to configure. Said out loud because a reader who knows the Claude behaviour will otherwise look for the scope in the catalog above, not find it, and assume refresh is off.
Related
protect()reference- Routers — every endpoint
- Security — what makes revocation instant