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:
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
These limits keep a self-registered client from being more powerful than a hand-registered one:
- 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. A self-registered client always acts for a person who consented. - Clients are public (
token_endpoint_auth_method: "none") unless the token or policy setsallowConfidential, which permits a client secret (client_secret_basicorclient_secret_post). - Redirect URIs must use HTTPS, loopback HTTP on any port (how desktop apps receive the code, per the native-apps
standard RFC 8252), or a reverse-domain custom scheme for native apps (
com.example.app:/callback). - Nothing the provider would have to fetch can be registered:
jwks_uri,sector_identifier_uri,backchannel_logout_uri,initiate_login_uri, request URIs, or custom lifetimes. Inlinejwksis refused too. A stranger cannot make your server call URLs of their choosing. - A client that omits
scopegets the allowance. One that asks for more is refused. It may request tokens only for the allowance'sresources.
Manage registered clients and tokens
New clients are audited as iam:oauth:RegisterClient, appear in listClients with registeredVia (the token ID
or anonymous), and are managed like any other client.
| Method | Permission | What it does |
|---|---|---|
listRegistrationTokens(credential, { tenantId }) | iam:oauth:clients:read | Lists a tenant's tokens (name, limits, used, expiry, revoked) so administrators see what is outstanding. Never returns the token itself. |
revokeRegistrationToken(credential, { tenantId, tokenId }) | iam:oauth:clients:delete | Stops a token from registering more clients, for example when a rollout ends. Clients it already registered stay. |
updateClient, revokeClient | iam:oauth:clients:update, iam:oauth:clients:delete | Change or remove a registered client, like any other. |
Registration management (registration_client_uri, which would let a client edit itself) is not offered, so
administrators change and revoke registered clients through updateClient and revokeClient.
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.
Next steps
Better IAM is created by Sean Filimon
Last updated
Resource servers and tokens
Issue audience-restricted JWT access tokens for your APIs, verify them offline, bind them to keys with DPoP, and delegate calls with token exchange.
Shared Signals
Push signed CAEP and RISC security events about a tenant's people to SIEMs, applications, and partner IdPs as sessions end and accounts change.