Dynamic Client Registration
Authagonal implements OAuth 2.0 Dynamic Client Registration (RFC 7591), allowing client applications to register themselves at runtime without administrator involvement.
Enabling the Endpoint
Dynamic registration is disabled by default. Opt in via configuration:
{
"Auth": {
"DynamicClientRegistrationEnabled": true
}
}
Or set Auth__DynamicClientRegistrationEnabled=true as an environment variable. A multi-tenant host can override the setting per tenant through ITenantContext.DynamicClientRegistrationEnabled: a tenant’s own answer wins, and null falls back to the host-wide option.
When enabled, the discovery document advertises the endpoint:
GET /.well-known/openid-configuration
{
"registration_endpoint": "https://auth.example.com/connect/register"
}
Registering a Client
POST /connect/register
Content-Type: application/json
{
"client_name": "My App",
"redirect_uris": ["https://myapp.example.com/callback"],
"post_logout_redirect_uris": ["https://myapp.example.com/"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "client_secret_basic",
"scope": "openid profile email offline_access",
"audiences": ["https://api.myapp.example.com"],
"allowed_cors_origins": ["https://myapp.example.com"],
"backchannel_logout_uri": "https://myapp.example.com/oidc/backchannel",
"frontchannel_logout_uri": "https://myapp.example.com/oidc/frontchannel",
"frontchannel_logout_session_required": true
}
Response
HTTP/1.1 201 Created
Content-Type: application/json
{
"client_id": "a1b2c3d4e5f6...",
"client_secret": "xkCd2_base64url...",
"client_id_issued_at": 1745000000,
"client_secret_expires_at": 0,
"client_name": "My App",
"redirect_uris": ["https://myapp.example.com/callback"],
"post_logout_redirect_uris": ["https://myapp.example.com/"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "openid profile email offline_access",
"token_endpoint_auth_method": "client_secret_basic"
}
The client_secret is returned once and cannot be retrieved later. Store it securely. The response is sent with Cache-Control: no-store. client_id is 32 lowercase hex characters, and client_secret_expires_at is always 0 (secrets do not expire). Public clients (none) and private_key_jwt clients get no client_secret in the response. The response echoes only the fields shown: audiences, jwks, jwks_uri, allowed_cors_origins and the logout fields are stored but not returned.
Request Parameters
| Parameter | Required | Notes |
|---|---|---|
client_name |
no | Defaults to the generated client_id if omitted |
redirect_uris |
conditional | Required when grant_types contains authorization_code. Must be absolute URIs; javascript:/data:/vbscript:/file: schemes are rejected (native custom schemes for mobile deep links are fine). A fragment is rejected (RFC 6749 §3.1.2), and cleartext http is accepted only for loopback hosts (RFC 8252 §7.3). At most 20 entries, each at most 2048 characters. |
post_logout_redirect_uris |
no | Valid redirect targets after logout. Same 20-entry / 2048-character bounds as redirect_uris. |
grant_types |
no | Defaults to ["authorization_code"]. Only authorization_code and refresh_token are registrable: client_credentials, implicit, device and any other grant type are rejected with invalid_client_metadata, so open registration can never mint a machine-to-machine client. refresh_token is added automatically if offline_access is requested. |
token_endpoint_auth_method |
no | client_secret_basic (default), client_secret_post, private_key_jwt, or none for public clients. Any other value is refused with invalid_client_metadata. |
jwks / jwks_uri |
with private_key_jwt |
The client’s public keys. One of the two is required for private_key_jwt (otherwise invalid_client_metadata); jwks_uri must pass the outbound-URL guard (an external address). A private_key_jwt client is issued no secret. |
scope |
no | Space-separated scopes. Only the five OIDC built-ins (openid, profile, email, phone, offline_access) plus whatever Auth:DynamicClientRegistrationScopes names are registrable: existence in the scope store is not enough (see Scopes). Role-gated scopes and the administrative scope (AdminApi:Scope, default authagonal-admin) can never be registered. |
audiences |
no | JWT aud values added to access tokens. At most 20 entries of at most 512 characters, each an absolute URI without a fragment; a bad value is invalid_client_metadata. |
allowed_cors_origins |
no | Each entry must be a valid origin (otherwise invalid_client_metadata), but the value is not stored as sent: the client’s allowed origins are derived from the origins of its own https redirect_uris, so a registrant can only reach origins it already proved a redirect URI for. |
backchannel_logout_uri |
no | Enables Back-Channel Logout |
frontchannel_logout_uri |
no | Enables Front-Channel Logout |
frontchannel_logout_session_required |
no | Defaults to true; when true, the logout URL carries iss and sid parameters |
Defaults & Invariants
- PKCE required:
RequirePkceis alwaystruefor dynamically registered clients. - Consent required:
RequireConsentis alwaystrue, so a user sees the consent screen for a self-registered client even where a statically seeded client would skip it. - Public clients:
token_endpoint_auth_method: "none"produces a client without a secret. PKCE is still required. - Offline access: requesting scope
offline_accessimplicitly addsrefresh_tokentogrant_types.
Error Responses
| HTTP | error |
Cause |
|---|---|---|
400 |
invalid_redirect_uri |
One of redirect_uris is not a valid absolute URI, uses a script/data/file pseudo-scheme, carries a fragment, is cleartext http to a non-loopback host, or (for either URI list) is longer than 2048 characters |
400 |
invalid_client_metadata |
A non-registrable grant type was requested, redirect_uris is missing for a grant type that requires it, token_endpoint_auth_method is unsupported, private_key_jwt has no jwks/jwks_uri (or an unsafe jwks_uri), audiences is invalid, an allowed_cors_origins entry is not an origin, or a logout URI is not an external address |
400 |
invalid_scope |
A requested scope is neither built-in nor registered |
400 |
invalid_client_metadata |
More than 20 redirect_uris / post_logout_redirect_uris |
403 |
invalid_scope |
A requested scope is not registrable: not in Auth:DynamicClientRegistrationScopes, or role-gated |
403 |
invalid_scope |
The administrative scope was requested, it can never be granted through registration |
403 |
invalid_scope |
A registered IClientScopeGuard refused a requested scope (the anonymous caller is passed to it) |
403 |
not_supported |
Dynamic client registration is not enabled |
429 |
rate_limited |
Too many registrations from this IP (10 per hour) |
Security Considerations
The registration endpoint is unauthenticated, but constrained by design:
- Rate limited: 10 registrations per source address per rolling hour (
429 rate_limited), so the client store can’t be flooded. The address is the one the caller cannot choose (the forwarded value is not trusted blindly). - Grant types restricted: only
authorization_code+refresh_token; a registered client always requires a user-mediated flow and can never act as a machine-to-machine client. - Scopes allowlisted, not inherited: a registrant may declare the five OIDC built-ins and nothing else unless an operator lists a scope in
Auth:DynamicClientRegistrationScopes. Existence in the scope store is not permission: a scope exists because some client needs it, not because every anonymous registrant may claim it. - Admin scope reserved: the
authagonal-adminscope (or whateverAdminApi:Scopeis set to) is refused, so registration can never produce a client that reaches the admin API. - Logout URIs validated:
backchannel_logout_uriandfrontchannel_logout_uriare dereferenced by the server, so they must be external http(s) endpoints: loopback, RFC1918, link-local (including the cloud metadata address) and.internal/.localhosts are refused. - Bounded records: at most 20 redirect URIs of at most 2048 characters each, so one registration cannot be used to inflate the client store.
- CORS origins derived, not trusted: the stored origins come from the client’s own
httpsredirect URIs, never from the request body. - PKCE always required, and consent always required, on registered clients.
What it does not constrain, unless the registrant opts in, is the audience. RFC 7591 has no field for one, so a stock registration omits audiences (an Authagonal extension) entirely: the client was never asked, its list is “unset”, and it may name any absolute URI as its resource at the authorization endpoint and receive a token carrying that value as aud. That is deliberate (the MCP authorization spec requires clients to name the MCP server as the resource, and an MCP client is a DCR client), and it makes the resource server responsible for authorizing on scope rather than on iss + aud + sub. Sending audiences, even as an empty list, is an answer and pins the client to it: a non-empty list is the allowlist for resource, and an explicit [] means the client may not name a resource at all. Token exchange is the exception: there an unset Audiences denies outright, so a registered client cannot aim an exchanged token anywhere. See Audiences and resource indicators.
For stronger gating (initial access tokens, mTLS, software statements), front the endpoint with your own middleware or an IAuthHook. Consider disabling dynamic registration entirely and managing clients via the admin API in environments where self-service registration is not a requirement.