Dynamic registration and MCP
Let MCP hosts and other self-configuring clients discover your authorization server, register with RFC 7591, and call your protected APIs.
The Model Context Protocol (MCP) lets AI assistants and IDEs (the MCP host) call tools that your product exposes through an MCP server. When that server holds customer data, the assistant has to act as a particular person, with that person's permission, just like any other app. MCP uses OAuth for this.
The problem it solves. Normally an administrator registers every OAuth client by hand and copies its client ID into the app. That cannot work for MCP: thousands of people run their own copy of a desktop assistant, and each copy connects to servers it has never seen. Two standards let such a client configure itself:
- Protected resource metadata (RFC 9728): the MCP server publishes a small JSON document that says which authorization server issues its tokens. A client that gets a 401 reads it and knows where to go.
- Dynamic client registration (RFC 7591): the client registers itself with the authorization server over HTTP and receives a client ID, within limits you set.
After that the client runs the normal authorization code flow, the person signs in to Better IAM and approves the assistant, and the assistant calls the MCP server with a token meant only for it. Better IAM supplies both halves:
- On the OAuth/OIDC provider,
registrationturns on dynamic registration, limited to one and to the scopes and APIs you allow. - In front of the MCP server (your API),
createResourceGuardserves the protected resource metadata, verifies access tokens, and answers failures with the challenge MCP hosts expect.
The same pieces work for any self-configuring OAuth client, not only MCP.
Who configures it. Your team decides the registration policy in code and puts the guard in front of the MCP server. Tenant administrators can hand out registration tokens when they want tighter control than open registration, and they manage the registered clients like any other. The people using an assistant only sign in and approve it.
How an MCP host connects
The host starts with nothing but the MCP server's URL. Each step below discovers the next piece, so no one has to configure the host by hand:
The consent screen shows the registered client_name. Consenting binds the client to the person's IAM
, so signing out or deactivation revokes it like any other consent.
Turn on dynamic registration
Declare the MCP server as a resource server and configure
registration:
import { createOAuthProvider } from 'better-iam/oauth';
const issuer = createOAuthProvider({
...options,
resourceServers: { 'https://mcp.example.com/mcp': { scopes: ['mcp:tools'] } },
registration: {
// Optional: accept registrations without a token (what most MCP hosts do), for the tenant the host serves.
anonymous: ({ headers }) =>
tenantForHost(headers.host)
? {
tenantId: tenantForHost(headers.host)!,
scopes: ['openid', 'offline_access', 'mcp:tools'],
resources: ['https://mcp.example.com/mcp'],
maxClients: 500,
}
: undefined,
},
});The discovered registration_endpoint ({issuer}/reg) accepts registrations in two ways. Choose by how much you
trust the clients:
Open registration, for public MCP servers. Most MCP hosts register without any token. The anonymous hook
decides what such a request gets: it receives the request's headers and ip and returns the policy (above: the
tenant that owns the host name the request came to), or undefined to decline. Anonymous registrations are capped
per tenant by maxClients (default 100), so an open endpoint cannot be used to create unlimited clients.
A registration request looks like this:
POST /oidc/reg
Content-Type: application/json
{
"client_name": "Acme Assistant",
"redirect_uris": ["http://127.0.0.1:33418/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none",
"scope": "openid offline_access mcp:tools"
}A missing, invalid, expired, or used-up registration token, a declined anonymous request, or a suspended tenant
answers 401 with error: invalid_token.
Registration policy
The anonymous hook returns, and a registration token stores, the same limits:
Prop
Type
createRegistrationToken also takes name (required, to recognize the token later) and expiresIn in seconds
(default 7 days, between one minute and one year). It requires iam:oauth:clients:create, returns the token once,
and stores only its hash.
What a registration may contain
A self-registered client is never more powerful than a hand-registered one. In short:
- The tenant comes from the token or the hook. A
tenant_idin the request is ignored. - Only the
authorization_codeandrefresh_tokengrants are allowed, always with PKCE, so a self-registered client always acts for a person who consented. - Clients are public (
token_endpoint_auth_method: "none") unless the policy setsallowConfidential. - Redirect URIs use HTTPS, loopback HTTP on any port (how desktop MCP hosts receive the code, per RFC 8252), or a reverse-domain custom scheme.
- Nothing the provider would have to fetch can be registered (
jwks_uri,backchannel_logout_uri, and similar), and scopes stay within the allowance.
The complete rules, the registration-token administration methods (listRegistrationTokens,
revokeRegistrationToken), the stored schema, and every registration error are on
Dynamic client registration. Registered clients appear in
listClients with registeredVia (the token ID or anonymous) and are changed or removed with updateClient and
revokeClient, like any other client. Clients cannot edit their own registration: registration_client_uri is not
offered.
Protect the MCP server
createResourceGuard is everything the MCP server needs in front of its routes: it serves the protected resource
metadata document, verifies access tokens, and answers failures in the form MCP hosts understand.
import { createResourceGuard } from 'better-iam/oauth';
const guard = createResourceGuard({
resource: 'https://mcp.example.com/mcp',
authorizationServers: ['https://id.example.com/oidc'],
scopes: ['mcp:tools'],
requiredScopes: ['mcp:tools'],
resourceName: 'Acme MCP',
});
async function handle(request: Request): Promise<Response> {
const { response, token } = await guard.check(request); // also serves /.well-known/oauth-protected-resource/mcp
if (response) return response;
return runMcp(request, token); // token.subject, token.tenantId, token.clientId, token.scopes
}guard.check(request, { scopes }) returns either { response }, which you send back as is, or { token } for an
authorized request. Pass scopes for routes that need more than requiredScopes.
| Request | Answer |
|---|---|
GET the metadata URL | The metadata document, readable from any origin, cached for an hour. |
No Authorization header | 401 with WWW-Authenticate: Bearer resource_metadata="…", scope="…" and no error code, as the bearer-token standard (RFC 6750) requires. This is what starts discovery in the host. |
| Invalid, expired, or wrong-audience token | 401 invalid_token. |
| Missing scopes | 403 insufficient_scope, naming the scopes needed. |
| Valid token | { token } with the verified token fields. |
Bearer and DPoP-bound tokens are both accepted.
Prop
Type
The guard also exposes metadataUrl and its verifier.
Lower-level pieces
If your framework already has its own auth middleware, use the parts:
protectedResourceMetadata(options)returns the RFC 9728 document as an object.protectedResourceMetadataUrl(resource)returns where it lives:/.well-known/oauth-protected-resourceinserted before the resource's path, for examplehttps://mcp.example.com/.well-known/oauth-protected-resource/mcp.createProtectedResourceHandler(options)serves the document (GET,HEAD, and CORS preflight) and returnsundefinedfor every other request, so it can sit in front of your routing.- The verifier's
challenge(error, realm, { resourceMetadata, scopes })builds aWWW-Authenticatevalue that points clients at the metadata.
Refresh tokens for MCP clients
Refresh tokens follow OAuth 2.1 (the consolidated revision of OAuth 2.0 that MCP builds on) for these requests, so
an assistant stays connected without asking again every 15 minutes. An authorization code without the openid scope yields a refresh token whenever the client is
registered for the refresh_token grant, with no offline_access or prompt=consent needed. OpenID requests keep
the OIDC rule: they need offline_access. Refresh tokens rotate on every use and end with the consenting session.
Endpoints
| Endpoint | Served by | Path |
|---|---|---|
| Protected resource metadata (RFC 9728) | createResourceGuard in front of the MCP server | GET /.well-known/oauth-protected-resource{resource path} on the MCP server's origin |
| Authorization server metadata (RFC 8414) | The provider | GET /.well-known/oauth-authorization-server{issuer path} |
| Registration (RFC 7591) | The provider, when registration is set | POST {issuer}/reg |
| Authorization | The provider | GET {issuer}/auth |
| Token | The provider | POST {issuer}/token |
Errors
| Where | Answer | When |
|---|---|---|
| Registration endpoint | 401 invalid_token | Missing, invalid, expired, or used-up registration token; declined anonymous request; inactive tenant. |
| Registration endpoint | 400 invalid_client_metadata | Metadata outside the policy. See Dynamic client registration. |
guard.check | 401 invalid_token | Invalid, expired, or wrong-audience token (INVALID_TOKEN). |
guard.check | 403 insufficient_scope | A required scope is missing (INSUFFICIENT_SCOPE). |
createRegistrationToken | INVALID_INPUT | Missing name, a lifetime outside one minute to one year, or unsupported scopes, resources, or limit. |
Next steps
Every registration rule, registration token administration, schema, and errors.
Resource servers and tokensResource server options, the verifier, DPoP, and token exchange.
OAuth/OIDC providerInteraction pages, consent, and grant revocation.
Secure an MCP serverA step-by-step guide from an empty project to a protected MCP server.
Better IAM is created by Sean Filimon
Last updated
Dynamic client registration
Let OAuth clients such as MCP hosts and native apps register themselves at the provider's registration endpoint, limited by tenant-scoped registration tokens or an anonymous policy you write.
Verifiable credentialsnew
Issue signed SD-JWT credentials that people keep in a digital wallet, with selective disclosure, holder binding, and status-list revocation.