An Application in Modgud is the organisational clamp around a SaaS
app — it owns its own permission catalog, its own roles, and its own
OAuth bindings. When a realm is created the system app modgud (=
Modgud itself) is provisioned automatically; every other app you
register here.
An Application is not an isolation boundary — that is the realm
(tenant): own database, signing keys, OIDC issuer, user pool. An
Application is a soft facet within a realm: it shares the realm's
user pool (one account, one sub, across all of a realm's apps — no
shadow users), and on top of the permission clamp it can carry its own
login-experience — an optional subdomain, branding, email branding,
and per-app overrides of self-registration / native-grant / DCR / CIMD
policy. Those live in Application settings below.
See Concepts → Apps & resource access
for the model.
::: tip First time? If this is your first integration, the SaaS App Integration Walkthrough is the better entry point — it walks through all five stations (App, Client, Resource Server, Roles, backend code). :::
Modgud manages permissions as two-segment <resource>:<action>
strings inside an App's catalog — the app slug is the implicit context,
not part of the string. The same string invoice:write in the billing
app's catalog and in the shipping app's catalog are different
permissions, distinguished by the audience the gate is running for.
An app therefore bundles:
- Permission catalog — the
<resource>:<action>entries that the app's resources understand - Roles with
AppId— bundles ofPermissionIdsfrom this app's catalog - Groups via
BoundTo— which organisational unit is active in which app - OAuth Clients via their
AppIdslist — which token requesters serve the app - OAuth APIs (Resource Servers) via their
AppId— which backend identities belong to it - OAuth Scopes via their
AppId— which scopes a client of the app may request
| Field | Meaning |
|---|---|
| Slug | URL- and permission-safe identifier. Lowercase, 3-63 characters, letters/digits/hyphens. Immutable after creation. |
| Display Name | What appears in lists and consent screens |
| Description | Optional, one-liner |
| Permission catalog | <resource>:<action> entries this app's resources can be gated by |
| IsSystem | True only for modgud and control-plane; cannot be deleted |
These slugs are forbidden — they collide with the permission grammar or with system invariants:
realm— would clash withrealm:admin(realm-wide bypass)*— wildcard inGroup.BoundTomodgud— system app, seeded automatically into every realmcontrol-plane— control-plane system app, seeded only on the Control-Plane realm
Click Create in the list view.
- Pick a slug — kebab-case, memorable:
acme,billing,inventory. Not changeable. - Fill in display name and description.
- Add catalog entries:
<resource>:<action>, kebab-case both sides (invoice:read,invoice:write,invoice:admin). - Create.
The app appears in the list. It still has no effect on its own — you also need to:
- link at least one OAuth client to it (OAuth Clients)
- (for an authenticated server-to-server callback into Modgud) provision a resource server
- create at least one role + group that connects users to the app
The slug is immutable, so the way to stand up a near-identical app — or to effectively rename one — is to clone it. In the list, right-click a row → Clone. The Create modal opens pre-filled from the source:
- Slug is left blank — give the copy its own (the source's can't be reused).
- Display name, description and the whole permission catalog are copied. The catalog entries are copied as new entries (fresh ids), so the source app's role grants and resource-server subsets are left untouched.
- Settings are copied too — branding, registration, native-grant / DCR / CIMD overrides — except the Origin subdomain, which is globally unique and would collide. Set a new subdomain on the copy if it needs one.
To rename: clone the app, give the copy the new slug, re-point the dependent clients / scopes / APIs / roles / groups, then delete the original.
Under OAuth → APIs, create an OAuth API named after
the app's slug and link it to the app. Its PermissionIds declare
which subset of the catalog this resource server gates on (full
catalog is the typical default; tighten for microservices that only
need a slice). This is the identity Modgud uses to compute the
per-Audience resource_access block in UserInfo.
Catalog entries can be edited any time, but:
- Adding is harmless. Existing role assignments remain valid; new permissions become assignable.
- Removing is dangerous. Roles that reference the removed entry silently lose the grant. The admin UI shows a "rename" indicator and a delete-block prompt when something downstream is still referencing a catalog entry. Audit roles before dropping.
Beyond the permission catalog, an Application can override a slice of the realm's configuration and carry its own login experience. Open the app from the list and switch to the Settings tab (disabled for the system apps). Everything here is optional and sparse — an App overrides only what you switch on; anything left off inherits the realm value, field by field. Clearing an override re-inherits the realm.
| Section | What it does |
|---|---|
| Origin | The App's own subdomain (e.g. amzettel.cocoar.app), which must be a child of the realm's primary domain. Setting it routes that host to this App and serves the branded login there; clearing it falls back to the tenant URL. The OIDC issuer stays the tenant's (anchored to the realm primary domain) — a subdomain is not its own issuer. |
| Branding | Product name, primary colour, logo/favicon — the look of the login + consent UI when reached via this App. |
| Email branding | The product name used in this App's outbound emails (OTP, magic link, ...) instead of the realm default. |
| Self-registration | Per-app override of the realm self-registration policy (allowed email domains, admin approval, default groups, ToS/privacy URLs) plus the posture (see below). Captcha stays realm-level. |
| Registration fields | Per-app override of which identity fields (username / first / last name) are required when an account is created — each one inheriting the realm by default. See Registration fields below. |
| Native grants | Per-app toggle + token lifetimes for the cookieless native passwordless grants. |
| DCR | Per-app override of Dynamic Client Registration (enable, token lifetimes, rate limits, reserved-name blocklist). |
| CIMD | Per-app override of Client-ID Metadata Documents (enable, token lifetimes). |
The self-registration section carries a posture that decides how a passwordless sign-up is triggered for the App:
| Posture | Behaviour |
|---|---|
JitOnOtp (default) |
Sign-in-or-sign-up: an unknown email at the native OTP endpoint creates a passwordless user and emails a one-time code; redeeming it both verifies the mailbox and signs in. The low-friction consumer default. |
Off |
No self-registration — an unknown email gets the uniform anti-enumeration response, no user is created. |
ExplicitEndpoint |
Registration is a deliberate, separate step via POST /api/account/native/register (room for an app's own ToS / profile UI); sign-in stays strict — the OTP-request endpoint serves only known users. An unknown email at the register endpoint creates the passwordless user and emails the same registration code. |
InviteCode |
Invite-only: an unknown email becomes a passwordless user only when the native sign-up request carries a valid, unused, unexpired invite code. Existing confirmed users still sign in normally (no code needed). Code failures are indistinguishable from Off (anti-enumeration). |
See Integrate → Native apps for the end-to-end flow.
Set the posture to InviteCode to run an app invite-only — a new person
gets an account only by presenting a single-use code. The code is app-bound,
optionally email-bound (bearer by default), hashed at rest, and expires
(default 14 days). Two ways to mint, and you only need to set up the second
one if a backend should mint automatically:
-
Admin UI (works immediately, no setup). Open Invite Codes in the admin sidebar (OAuth & Federation), pick the app, and bulk-mint. The plaintext codes are shown once — copy them then; only their hashes are stored. Gated by the
invite-code:read/invite-code:writepermissions. -
Machine-to-machine (the consuming app's backend). For a backend that mints codes itself (e.g. when a user invites someone), there is a one-time setup:
- Create an OAuth scope named
invite:writethat is bound to this app (set its App-ID) — see OAuth scopes. Scope names are unique per realm, so in a multi-app realm name the scope per app. - Give a ServiceAccount a credential (a confidential
client_credentialsclient) carrying that scope. - The backend then calls
POST /api/app/{appId}/invite-codes{ "Count": N, "BoundEmail": null, "ExpiresInDays": 7 }with itsclient_credentialsaccess token. The response carries the plaintext codes once. The{appId}must match the scope's app — a cross-app or cross-tenant caller is rejected.
- Create an OAuth scope named
The public mobile client never mints — it only redeems a code it was handed,
by passing it on the native sign-up request (InviteCode field on
POST /api/account/native/otp/request). Redemption confirms the mailbox via the
usual OTP step. See Integrate → Native apps for the
redemption flow.
Identity vs. authorization. Modgud's invite code only governs who may exist. What the invite is for (join list L, beta access, …) stays in the consuming app — Modgud only ever learns
(email, code, appId).
Overrides the realm's Registration Fields
policy for this App — which identity fields (username / first / last name) are
required when an account is created here. Each field is a tri-state
(Off / Optional / Required) that inherits the realm when left unset,
so a Consumer App can stay email-only inside the same tenant where an Enterprise
App requires a real name.
The resolved (App ⊕ realm) policy is published at GET /api/app-info, so the
App's clients render exactly the inputs it requires. When an App requires a
field, its native clients must collect and send it (FirstName / LastName
on the native OTP / register calls) — otherwise registration fails. Email is
always required and is never configurable.
Captcha (needs a per-app secret store), account deletion / audit / custom pages (operational / GDPR), and the DCR garbage-collection interval (the GC job iterates per realm) are not per-app overridable — set them in Realm settings.
An App is one resource, so these overrides ride inline on the app itself —
GET /api/app/{id} returns them, and POST/PUT /api/app write them in the
same call that creates or updates the app (app:read / app:write), in one
tenant transaction. There is no separate settings endpoint. See the
Admin API reference.
| Linked with | Where | How |
|---|---|---|
| OAuth Clients | OAuth Clients | n:m via the client's AppIds list |
| OAuth Scopes | OAuth Scopes | 1:n via the scope's AppId (or null = global) |
| OAuth APIs (Resource Servers) | OAuth APIs | 1:n via the API's AppId |
| Roles | Roles | n:1 via the role's AppId |
| Groups | Groups | n:m via the group's BoundTo list |
The app modgud represents the Modgud admin surface itself.
Permissions like user:read or oauth-client:write (in this app's
catalog) gate the admin UI sidebar.
- Auto-seeded on first realm setup
- Not deletable (
IsSystem = true) - Slug not renameable
- Catalog matches the built-in admin surface — edit cautiously
Seeded only into the realm flagged IsControlPlane = true. Owns
the realm:read / realm:write permissions that gate
/api/admin/realms/*. See
Concepts: Control Plane / Data Plane.
If you change a system app's catalog, the admin sidebar may hide items
because the corresponding permission is no longer registered. When in
doubt, restore the default catalog (see AppRealmSeeder in source).
System apps cannot be deleted. Regular apps can — but:
- OAuth clients with the app in their
AppIdslist keep the entry (UI shows it as "unknown app") - OAuth scopes with this AppId become orphaned
- Roles with this AppId stay — but their
PermissionIdsno longer resolve to anything - Groups with the app in BoundTo keep the entry, but it no longer has effect
So before deleting: re-link or delete the dependent clients, scopes, and roles first.