Realm Settings
Realm Settings are realm-wide configuration owned by the realm admin (not the Control-Plane admin). They live in the tenant database as a singleton document and are managed via Administration → Realm Settings.
Realm structure vs. Realm settings
- Realms (structural metadata: slug, domains, Control-Plane flag, active state) are managed by the Control-Plane admin only.
- Realm Settings (self-registration, DCR policy, branding, …) are managed by the realm admin inside their own realm. The Control-Plane admin reaches their own realm's settings the same way as any other realm admin would — through this page.
These are the realm defaults — Applications can override them
The Self-Registration, Registration-Fields, DCR, CIMD and Native Passwordless Grants policies (and branding / email branding) set here are the realm defaults. An individual Application can override a slice of them per-app (sparse, field by field — anything it doesn't set inherits the realm value here). Captcha, account deletion and the DCR garbage-collection interval stay realm-only.
Tabs
The page currently has these tabs:
- Self-Registration — public sign-up policy
- Registration Fields — which identity fields are required when an account is created
- Dynamic Client Registration — anonymous OAuth-client registration policy (linked detail page: Dynamic Client Registration)
- Client ID Metadata Documents — fetch-on-demand OAuth-client identification by HTTPS-URL
client_id(linked detail page: Client ID Metadata Documents). Off by default. - Native Passwordless Grants — the per-realm master toggle for the cookieless
urn:cocoar:*grants (see Native app integration) - Rate Limits — per-IP request ceilings for the realm's auth endpoints
- Account Deletion — grace period and recycle-bin retention policy
- Signing Keys — rotate the realm's OAuth/OIDC token-signing key
Per-realm branding is configured on a separate page under Platform — see Customization → Branding.
Permissions: realm-settings:read / realm-settings:write. The realm-admin role grants both via the realm:admin bypass.
Self-Registration
Public sign-up: visitors can create an account themselves at /register. Opt-in per realm, disabled by default — anonymous probes to /api/account/self-registration-info return the all-defaults shape so disabled realms can't be enumerated.
Enabling self-registration
- Open Administration → Realm Settings → tab Self-Registration.
- Check Enable self-registration.
- Configure the additional fields that appear (see below).
- Save.
Once enabled, the login page picks up a "No account yet? Register →" link and /register becomes reachable.
Fields
| Field | Default | Meaning |
|---|---|---|
| Enable self-registration | off | Master toggle. When off, the /register route returns the same anti-enumeration response as a never-registered email. |
| Require email verification | on | New accounts are created with EmailConfirmed=false and can't sign in until they click the magic-link in the verification mail. Turn off only for trusted-internal scenarios. |
| Require admin approval | off | Layered on top of email verification — after the user confirms the link, the account stays IsActive=false until an admin flips the flag manually. Useful for moderated communities. |
| Allowed email domains | empty (all) | Whitelist. Empty = accept any domain. Case-insensitive match on the part after the last @. |
| Default groups | empty | Groups the new user is auto-attached to once the account is fully active (post-verification + post-approval). Role memberships flow through groups, so this is the lever for "what can self-registered users do?". |
| Terms-of-Service URL | empty | When set: the registration form shows a required "I accept" checkbox linking here. The endpoint rejects submissions without the checkbox ticked. |
| Privacy Policy URL | empty | When set: rendered as a discreet footer link on the registration form. No checkbox. |
| Enable Cloudflare Turnstile captcha | off | Independent of the master toggle. See Captcha below. |
| Captcha site key | empty | Per-realm Turnstile site key. Empty + captcha enabled = falls back to the Cocoar-default keys. |
| Captcha secret | not set | Per-realm Turnstile secret, encrypted at rest. Write-only — never returned, only an "is configured" flag. Empty + save = clear (revert to Cocoar default). |
Captcha
Cloudflare Turnstile is the only supported captcha provider. It is independent of the master toggle so two scenarios both work:
- Public-internet deployment → enable captcha, configure either per-realm keys or rely on the Cocoar-default keys configured via the
Turnstile__SiteKey/Turnstile__SecretKeyenvironment variables. - Air-gapped / intern deployment → leave captcha disabled. Modgud then never calls out to
challenges.cloudflare.com. Honeypot field + per-email rate-limit (1/min, 3/hour) cover the bot-spam surface.
Resolution order when the captcha is enabled:
- Per-realm site key + per-realm secret if both set
- Cocoar-default site key + Cocoar-default secret
- None configured → the verifier rejects every registration and logs a
WARNso the admin notices the misconfiguration
One captcha secret per realm
The captcha secret is encrypted with ASP.NET Data Protection (purpose Modgud.SelfRegistration.CaptchaSecret.v1). Migrating data-protection keys between deployments invalidates all per-realm captcha secrets — they need to be re-entered. The same warning applies to login-provider client secrets.
Anti-enumeration
The public endpoints are explicitly engineered against enumeration:
POST /api/account/registeralways responds with the same generic success message regardless of outcome: existing email, existing username, captcha failure, honeypot trigger, rate-limit, domain-whitelist rejection — all look identical to a real success from the client's perspective. No mail is sent in the rejected cases.GET /api/account/self-registration-inforeturns the same all-defaults shape (Enabled=false) whether the realm has the feature off, doesn't exist at all, or is currently being configured. The SPA readsEnabledto decide between rendering the form vs. redirecting to/login.
The email-verification endpoint (POST /api/account/register/verify-email) is the exception — it returns real error codes for expired / used / unknown tokens, because by the time someone is consuming a token they already have it, and there is nothing left to enumerate.
What's stored where
| Data | Location |
|---|---|
| Realm-settings document (the toggles above) | tenant DB, singleton document RealmSettings |
| Pending self-registration (token-hash, user ID, expiry) | tenant DB, mt_doc_pendingselfregistration |
| User record | tenant DB, mt_doc_applicationuser (created at register time, activated on verify) |
| Captcha secret (encrypted) | inside the RealmSettings document, encrypted with Data Protection |
Known limitations (current MVP)
- No dedicated "pending approvals" UI. When admin-approval is required, the user record is created with
IsActive=falseand an admin has to flip the flag from the regular user-edit modal. A filter chip on the user list for "pending approval" is a sensible follow-up. - In-memory rate limiter. The per-email cap (1/min, 3/h) resets on restart and isn't shared across multiple instances. Single-instance deployments are fine; multi-instance setups can bypass the cap by hopping between instances. A Redis-backed implementation behind the same interface is a follow-up.
- No pre-submit username availability check. The form surfaces username collisions through the generic 200-OK like every other rejection. An anonymous
GET /api/account/check-username/{name}(rate-limited) would improve the UX without touching the anti-enumeration guarantees on email. - Email template is shared with the email-change flow. Both reuse
EmailTemplate.EmailVerification. A dedicatedEmailTemplate.SelfRegistrationVerifywith welcome wording is a quality-of-life improvement.
Registration Fields
Controls which identity fields are required when a user account is created — across every creation path (admin create/edit, public self-registration, native passwordless registration). Email is always required and is the anchor every other field is resolved against; it is not configurable.
Each of the other three fields is a uniform tri-state. The default is Optional for all three, which is exactly the historical behaviour — a realm that never touches this tab behaves as before (zero change).
| Field | Off | Optional | Required |
|---|---|---|---|
| Username | hidden; the username is always the email | shown; blank → the email | a non-empty username must be supplied |
| First name | not collected | shown, may be blank | must be supplied |
| Last name | not collected | shown, may be blank | must be supplied |
Why configure it
- Consumer apps (e.g. a task-management app) stay email-only — leave everything
Optional(or setUsername = Off) for the lowest-friction passwordless sign-up. - Enterprise apps typically want a real first/last name on every account — set them
Required. This is coherent with enterprise tenants that disable self-registration, but it is enforced on all paths regardless.
How it is enforced
- A missing required field is rejected on every creation path. On the admin and native endpoints it surfaces as a hard
400; on the anti-enumeration self-registration endpoint it is folded into the uniform generic response. - The check is independent of whether the email already exists, so it never leaks account existence.
- The resolved policy is published anonymously at
GET /api/app-info(RegistrationFields), so native apps and the web register form render exactly the inputs the realm (or App) requires.
Per-Application override + native clients
A single Application can override this policy (e.g. an Enterprise App requiring names inside a tenant whose realm default is lenient). When an App requires a field, its native clients must collect and send it — see Integrate → Native apps.
Federation (SSO) is lenient
Just-in-time accounts created from an external IdP (OIDC/SAML) are not held to the required-field policy today — they take whatever given_name / family_name claims the IdP provides. Tightening this is a possible follow-up.
Dynamic Client Registration
Anonymous OAuth-client registration policy: master toggle, token lifetimes, GC TTL, rate limits, reserved-name blocklist. The companion per-API and per-Scope toggles live on OAuth APIs and OAuth Scopes respectively — DCR is a triple opt-in by design.
Off by default. See the full feature page for when to enable it, what gets accepted, and the consent-screen [unverified] marker:
→ Dynamic Client Registration (full feature page)
Rate Limits
Per-IP request ceilings for this realm's auth endpoints. Each policy is a max requests / window (minutes) pair, partitioned by source IP and applied per realm. The shipped defaults are the secure production posture — the knob exists so a test realm, dev, or a legitimately bursty consumer can raise a ceiling without a modgud code change + redeploy, and so a hardened realm can tighten one.
| Policy | Endpoint | Default |
|---|---|---|
| Native OTP request | POST /api/account/native/otp/request (+ native register) | 5 / 60 min |
| Magic-link request | POST /api/account/magic-link/request | 5 / 60 min |
| Password-reset request | POST /api/account/forgot-password | 5 / 60 min |
| Email-OTP login verify | POST /api/account/email-otp/login | 30 / 1 min |
| Email verification resend | POST /api/account/email/send-verification | 5 / 60 min |
| Passkey ceremony begin / enroll | POST /connect/passkey/begin (+ enroll) | 60 / 5 min |
| First-admin bootstrap | POST /api/account/bootstrap-admin | 10 / 15 min |
Notes:
- A realm that never touches this tab behaves exactly as before — the defaults are the previously-hardcoded values.
- Limits are per realm: raising a ceiling on one realm does not affect another on the same shared IdP.
- The limiter is in-memory and resets on app restart; over-limit requests get
429 Too Many Requests. - A config change takes effect within a few seconds (a short cache smooths the per-request lookup).
- This governs the request rate; it is independent of per-challenge attempt counters (e.g. the email-OTP
MaxAttemptsbrute-force lock) and of the realm's anti-enumeration uniform responses.
Account Deletion
Controls the account-deletion lifecycle for this realm — the self-service grace period and the admin recycle-bin retention. These replace the old hardcoded 7-day confirm-token window. The mechanics of both flows are documented under Users → recycle bin & permanent erase and Profile → Privacy.
Fields
| Field | Default | Meaning |
|---|---|---|
| Grace period (days) | 30 | Self-service window: after a user requests deletion they stay able to sign in and cancel for this many days, then the account is auto-erased. |
| Reminder lead (days) | 2 | How many days before the grace deadline the "your account is about to be deleted" reminder email is sent. Must be less than the grace period to ever fire. |
| Admin retention (days) | 30 | Recycle-bin window: how long an admin-deleted (deactivated) account is kept before it becomes eligible for auto-purge. An admin can restore or permanently delete it at any point during this window. |
| Auto-purge | on | When on, a scheduled job permanently erases recycle-bin accounts once their retention window elapses. When off, the bin is only emptied by explicit admin action (Delete permanently). |
One job drives all three
A single scheduled sweep (account-lifecycle-sweep) does the work for the whole realm: it sends reminders, auto-erases self-service accounts past their grace deadline, and (when auto-purge is on) empties the admin recycle bin past retention. Changing these values takes effect on its next run.
Signing Keys
Every realm signs its OpenIddict access tokens and id tokens with its own RSA-2048 key — a token signed for realm A cannot validate against realm B's JWKS, so the keys are the cryptographic core of realm isolation. The key is generated lazily on first token issuance; this tab lets a realm admin rotate it on demand.
Rotating the key
- Open Administration → Realm Settings → tab Signing Keys.
- Click Rotate signing key and confirm.
On rotation:
- A fresh RSA keypair is generated and becomes the active signing key — all new tokens are signed with it immediately.
- The previous key is retired but kept in the realm's JWKS and verification set for a 30-day overlap window, so tokens issued just before the rotation (and resource servers that cache the JWKS) keep validating until they naturally expire / refresh.
- Once the overlap window elapses, the retired key is hard-deleted by the
signing-key-janitorscheduled job (see Scheduled Jobs). The in-memory verification set drops it on its own as soon as the window passes, even before the janitor runs.
When to rotate
Rotate on suspected key exposure or as scheduled hygiene — not casually. Resource servers that cache the JWKS very aggressively (longer than typical) may briefly reject freshly-signed tokens until they refresh their key set. The 30-day overlap is sized so a normally-behaving RS (which refreshes its JWKS far more often) never sees an interruption.
Operator alternative
Rotation can also be triggered from the recovery CLI without UI access: recover rotate-signing-key --realm <slug>. Both paths write an entry to the Auth Log.
Permission: realm-settings:write (the realm:admin bypass grants it).
Branding (separate page)
Branding lives on its own page under Platform — go to Platform → Customization → Branding. It writes a sub-document on the same RealmSettings doc but isn't surfaced as a tab here today.