Dynamic Client Registration
Dynamic Client Registration (DCR, RFC 7591) lets a piece of software register itself as an OAuth client without an administrator pre-provisioning it. Modgud ships an MCP-flavoured subset focused on AI agents and MCP servers: public PKCE clients only, no client secrets, audience-bound tokens.
Off by default
Every realm starts with DCR disabled. The POST /connect/register endpoint refuses requests, and the discovery document omits registration_endpoint — visitors can't tell whether the feature exists per realm.
Different from User Self-Registration
DCR registers software (an OAuth client). Self-Registration registers people (user accounts). Two unrelated concepts sharing the word "register".
Per-Application override
The DCR policy below is the realm default. An individual Application can override it per-app (enable/disable, token lifetimes, rate limits, reserved-name blocklist). The garbage-collection interval stays realm-only — the GC job iterates per realm.
When to enable it
Enable DCR when you want AI agents you don't pre-trust to be able to attach to your MCP server (or other OAuth-protected API) without an administrator walking each one through client creation.
Typical example: a user pastes your MCP server's URL into Claude Code, Cursor, Continue, or claude.ai. The MCP-spec authorization flow goes:
- Agent hits the MCP server with no token → 401 with
WWW-Authenticate: resource_metadata="…". - Agent fetches the protected-resource metadata, learns this realm is the auth server.
- Agent fetches
/.well-known/oauth-authorization-server→ seesregistration_endpoint. - Agent
POSTs its name + redirect URI to/connect/register→ gets back aclient_id. - Agent runs Authorization-Code + PKCE with
resource=<mcp-server-url>→ audience-bound access token.
Without DCR or CIMD, step 4 isn't possible and every agent has to be onboarded manually. DCR is the stored-client fallback for agents that don't support CIMD; with one pre-registered client an admin can pilot the integration, but "anyone with an agent attaches" needs DCR or CIMD.
For the full end-to-end walkthrough — registering the MCP server, wiring discovery, connecting a real agent, and revoking access — see Secure an MCP server with Modgud.
Triple opt-in design
Anonymous registration is gated three times. All three must be on for a DCR-registered client to be able to mint usable tokens.
| Layer | Where | Default |
|---|---|---|
| Realm master toggle | Realm Settings → Dynamic Client Registration tab | Off |
| Per-API allow-list | OAuth APIs → Allow DCR checkbox per row | Off |
| Per-Scope allow-list | OAuth Scopes → Dynamic Client Registration toggle per row | Off |
The master toggle just turns the registration endpoint on. The per-API flag controls which resource servers a DCR client can target with resource=. The per-scope flag controls which scopes a DCR client can ever request. A DCR-registered client that sets resource= to an API that hasn't ticked Allow DCR is rejected at the token endpoint with invalid_target.
How the per-Scope flag interacts with app-scoped scopes
Most scopes you create in Modgud are app-scoped — they belong to one Application (Scope.AppId is set). Non-DCR clients are restricted to scopes whose AppId matches one of their own linked Apps. DCR clients have no AppId by design (they're realm-wide public PKCE clients), so the per-Scope Allow DCR Clients flag replaces the app-link check for them. The scopes a DCR client can hold and request — its dynamic-client scopes — are:
- Any scope with
Allow DCR Clients = true, app-scoped or global. The realm-admin has explicitly opted this scope in for anonymous-registrant access. - The standard scopes other than
modgud.management:openid,profile,email,phone,address,offline_access,roles,permissions. They cannot carry the flag (standard scopes are immutable) and name no resource privilege of their own. The management-API selector is never available to a dynamic client.
A custom scope without the flag (default) — app-scoped or global — is not: /connect/authorize rejects the request with invalid_scope before the user ever sees the consent screen, with a description naming the scope and the flag to enable.
The combined effect: enabling DCR safely requires you to walk through your existing scopes once and decide which ones agents are allowed to ask for. Until you tick Allow DCR Clients on at least one scope, DCR clients can only request the standard scopes.
What a registration gets when it omits scope
RFC 7591 leaves it to the server ("a default set of scopes"). Modgud's default is the realm's dynamic-client scopes as they are at registration time; a registration with scope gets the intersection of what it asked for and that set — its own list is an upper bound, never a grant. The 201 response echoes the registered set in scope, so an agent can see what it holds. A client keeps the set it was registered with; a scope opted in later needs a re-registration (or a CIMD document, which is resolved live).
Enabling DCR for a realm
- Realm Settings → Dynamic Client Registration → enable.
- Set:
- Access-token lifetime (default 15 min) — shorter than admin-created clients on purpose; a leaked token has a smaller blast radius.
- Refresh-token lifetime (default 7 d). Rotation is global-on at the server level.
- GC TTL (default 90 d) — unused DCR clients get soft-deleted after this.
- Per-IP rate-limit (default 5/h), Per-realm rate-limit (default 100/d) — caps spray.
- Reserved names — substring blocklist for
client_name. NFKC-normalised + case-insensitive. Use it for your own trademark plus anything you don't want impersonated ("Cocoar", "Anthropic", …).
- OAuth APIs → your MCP-server API → tick Allow DCR.
- OAuth Scopes → the scope(s) the MCP server gates → enable the Dynamic Client Registration toggle.
After these four steps, an agent that POSTs to /connect/register with a valid payload gets a client_id back and can complete the full auth-code + PKCE flow against your opted-in API.
What's accepted at /connect/register
| Field | Rule |
|---|---|
redirect_uris | At least one usable: HTTPS, OR http://localhost, http://127.0.0.1, http://[::1], no fragment. Other forms (private-use schemes such as com.example.app:/cb) are dropped and the response echoes what was registered; none usable is invalid_redirect_uri. A loopback URI is matched without regard to its port at /connect/authorize (RFC 8252 §7.3) — including one registered with a port (http://127.0.0.1:49152/callback, as Zed does): Modgud registers its port-less twin too, and the response lists both. Scheme, host and path still have to match. |
application_type | Optional, web or native (OIDC DCR). A registration with a loopback http redirect URI is native whatever it declares, and the response says so. Anything but the two literal values is invalid_client_metadata. |
client_name | Optional (RFC 7591 §2). Truncated to 80 chars. A missing name, or one outside ASCII / Latin-1 after NFKC normalisation (the confusable-glyph defence), is not shown — the client is displayed under the host of its first https redirect URI, else "Unnamed application". A name matching a substring on the realm's reserved-names list (case-insensitive) is rejected. |
token_endpoint_auth_method | none (default — public PKCE client), or client_secret_basic / client_secret_post (confidential client; the secret is generated and returned once in the response). private_key_jwt needs a registered JWKS and requires admin pre-registration. |
grant_types | Must include authorization_code. Modgud registers the intersection with {authorization_code, refresh_token}; anything else a client lists (client_credentials, jwt-bearer, …) is dropped rather than rejected, and the response echoes what was registered (RFC 7591 §3.2.1). |
response_types | Must include code; only code is registered. No implicit / hybrid flows. |
scope | Optional, space-delimited. An upper bound intersected with the realm's dynamic-client scopes; omitted, the client gets the whole set. The response echoes what was registered. |
On success the endpoint returns 201 Created with the assigned client_id per RFC 7591 §3.2.1. On rejection — a body that is not JSON or has a field of the wrong type included — it returns 400 Bad Request with { error, error_description } per §3.2.2. Hitting the rate-limit returns 429.
Consent screen for DCR clients
DCR-registered clients always go through the explicit consent screen, with two extra cues:
[unverified]marker next to the client name.- Warning callout: "This app registered itself — verify the name carefully before authorizing."
AllowRememberConsent does not apply to DCR clients — Modgud decides per request instead, from whether the client's identity is assured (RFC 8252 §8.6). A remembered authorization skips the consent screen only when the redirect delivers the code somewhere only the real client can receive it: an https redirect URI on a non-loopback host, or a confidential client (client_secret_basic/client_secret_post, or private_key_jwt) redeeming with its own credential. A public client redirecting to loopback or a private-use URI scheme — the shape every native/CLI DCR client registers, such as Claude Code, VS Code, Zed or Cursor — sees the consent screen on every authorize, because any local process can catch a loopback redirect and redeem the code with its own PKCE verifier. Clients in that position avoid repeated prompts by retaining and refreshing the authorization they already obtained (typical pattern: the agent caches its own consent decision and reuses the refresh token instead of re-authorizing).
Re-affirming does not mint a new authorization. For the same user, client and scope set Modgud reuses the existing one, so the oi_au_id claim in the access token stays the same across re-consents. A resource server that keys per-connection state on sub + oi_au_id keeps that state. A new authorization (and a new oi_au_id) appears only when the scope set changes, when the old authorization was revoked, or when the client registers again under a new client_id.
Audit log
Every DCR-related event lands in the auth log with a DCR prefix in its message. The Auth Log grid's category filter chips are derived from whatever event categories are present, so look for DCR events under the operations (and, for rejected registrations, security-ops) chip rather than a dedicated DCR chip.
| Event | When |
|---|---|
DCR client registered | Successful registration. Fields: IP, Realm, ClientId, ClientName. |
DCR registration rejected | Validation rejected. Fields: IP, Reason (MissingRedirectUri, InvalidRedirectUri, ClientNameReservedName, …), ClientName. |
DCR rate-limit triggered | Per-IP or per-realm cap hit. |
DCR client first used | First successful token-issue for the new client_id. Cleanest signal that the registration was real, not bot noise. |
DCR client garbage collected | GC sweep soft-deleted a stale client. Fields: ClientId, RegisteredAt, LastUsedAt, TtlDays. |
Managing DCR-registered clients
The standard OAuth Clients grid carries a DCR column (●) and a "DCR only" filter chip. Clicking a DCR client opens the regular detail modal with an additional Registration Info tab showing:
- Registration timestamp (UTC)
- Source IP at registration time
- Last successful token-issue timestamp
You can delete a DCR client like any other — useful if a name slipped past the reserved-names list. The garbage collector also sweeps inactive ones automatically (default 90 days since last token issue).
What's NOT in v1
Deferred features with clearly-defined add-on paths:
software_statement(RFC 7591 §2.3) — vendor-signed JWT that replaces[unverified]with[verified by Anthropic]etc. Add when a real vendor publishes a stable signing key.- Initial Access Token (RFC 7591 §3.1) — admin-issued token required to register. Useful for paranoid realms; defeats the "agent attaches without admin involvement" use case.
- Approval workflow — DCR clients land in
pendinguntil admin reviews. - RFC 7592 management endpoints —
GET/PUT/DELETE /connect/register/{id}for updating already-registered clients. Re-registration is the v1 strategy. - Custom URI schemes —
com.example.app://callbackfor native apps.
Accepted risks
- Brand impersonation via creative
client_name— the reserved-names list catches direct hits, NFKC + Latin-1 catches lookalikes within Latin-1, but a sophisticated lookalike that doesn't match a configured term still passes. The[unverified]marker is the final defence, and it relies on the user actually pausing at consent. - Targeted phishing via HTTPS redirect — attacker registers a client with
redirect_uri=https://attacker.example/grab, then social-engineers a specific user to click through. The triple-opt-in constrains which resources/capabilities they can reach; the consent marker warns the user; no further filtering in v1. - Resource + scope targeting is the actual safety primitive — a DCR client's token is audience-bound to a specific opted-in API AND can only request opted-in scopes. Even if a code is grabbed, the resulting token can't be replayed against unrelated APIs and can't carry high-trust scopes.