Modgud lets native apps sign a user in without a browser redirect and without a cookie — the app talks to the token endpoint directly. This is the path for iOS/Android apps, CLIs, and other clients that can't (or shouldn't) host a web login.
It is built on three cookieless passwordless grants at /connect/token (ADR-0010) plus native passkey enrollment with a per-client WebAuthn RP-ID (ADR-0009):
| Grant | Factor | Best for |
|---|---|---|
urn:cocoar:otp |
Email one-time code | First sign-in / fallback |
urn:cocoar:magic |
Magic-link token | First sign-in / fallback |
urn:cocoar:passkey |
WebAuthn assertion (Face ID / Touch ID) | Steady-state login (MFA-grade) |
Typical lifecycle: the user signs in once with OTP or magic-link, the app enrolls a passkey for that account, and from then on uses passkey login.
These grants are off by default. They require two independent opt-ins — a per-realm flag and a per-client permission (see Admin setup). Until both are set, the token endpoint rejects the grants with
unsupported_grant_type/unauthorized_client.
What a realm administrator must configure in Modgud before the app can connect.
Realm Settings → Native Passwordless Grants → enable. Optionally adjust the access-token lifetime (default 15 min) and refresh-token lifetime (default 14 days). This is the master gate; while it's off, /connect/device-style native endpoints return disabled and the grants are rejected.
OAuth Clients → Create:
- Client type: Public — native apps cannot keep a secret, so they are public clients (no
client_secret; identified byclient_idonly). - Grants: add the native grants the app uses —
urn:cocoar:otp,urn:cocoar:magic,urn:cocoar:passkey. (They only appear in the grant picker once the realm flag in step 1 is on.) Addrefresh_tokenas well if you want long-lived sessions. - Scopes:
openid,offline_access(for a refresh token), plus any API scopes the app needs. - Redirect URIs: not required for these grants (there is no browser redirect).
Granting a native grant on the client sets the matching gt:urn:cocoar:* permission. Both the realm flag and this per-client permission must be present.
Server-side BFF (confidential redeem). "Public" is the right posture for a true native app that can't keep a secret. A backend-for-frontend that redeems the OTP / magic / passkey grant server-side (browser never touches Modgud) may instead use a confidential client — there is no public-only enforcement on these grants, so a
client_secretadds client authentication on top of the user's factor. If such a BFF also acts machine-to-machine (e.g. minting invite codes viaclient_credentials), that is a separate client: a single client cannot hold both user-flow andclient_credentialsgrants (see strict grant separation). So a dual-role BFF runs two clients — a login client carryingurn:cocoar:otp(public for a single shared secret, or confidential for client-auth on the redeem) plus a separate SA-linked client forclient_credentials.
For urn:cocoar:passkey, set the client's WebAuthn RP-ID to the app's branded apex (e.g. app.example.com). If left blank it falls back to the realm's primary domain.
Two things must be true app-side (Modgud never serves or routes the apex — it only validates the RP-ID inside the WebAuthn assertion):
- The app holds the
webcredentials:<rp-id>associated-domains entitlement. - The apex serves a valid
/.well-known/apple-app-site-association(AASA) file from the app's own infrastructure.
Changing the RP-ID later invalidates every passkey already enrolled for that client. Choose the apex deliberately.
All requests go to the realm's host (https://<realm-host>). Token requests are application/x-www-form-urlencoded POSTs to /connect/token with the public client_id (no secret, no PKCE — these are not the code flow).
The token response is standard OAuth:
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "…", // only when offline_access was requested
"scope": "openid offline_access"
}Step 1 — request a code (anonymous; uniform response, so it never reveals whether the email exists):
POST /api/account/native/otp/request
Content-Type: application/json
{ "Email": "user@example.com" }{ "Message": "If your email is registered, you will receive a verification code." }If the realm has native grants disabled, this endpoint does not return the uniform "code sent" response — it fails fast with
400 NativeGrants.Disabled. Whether the feature is enabled is a realm/App configuration state (not a per-email signal), so surfacing it is safe and saves you from a silent "no email, no error" dead end. Enable it under Realm Settings → Native Passwordless Grants (and give the client thegt:urn:cocoar:otppermission).
Step 2 — redeem the code for tokens:
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:cocoar:otp
&client_id=<client_id>
&username=user@example.com
&otp_code=123456
&scope=openid offline_access
&resource=<api-audience> # optional, narrows the token's aud (RFC 8707)
&totp_code=000000 # only if the user has TOTP 2FA enabledThe OTP request above signs in an existing user. For consumer apps,
an Application can also turn
the same endpoint into sign-in-or-sign-up by setting its
self-registration posture to JitOnOtp (the per-app default). Then, when
the request arrives on the App's subdomain and the email is unknown,
Modgud creates a passwordless user on the spot and emails a registration
code; redeeming it at /connect/token mints tokens and confirms the
mailbox in one step. No password, no separate registration call.
The endpoint behaves identically on the wire (same uniform response, same
anti-enumeration jitter — "code sent" leaks nothing about existence,
because under JitOnOtp an unknown email gets a code too). What changes is
governed entirely by the App's posture:
JitOnOtp— unknown email on the App subdomain → create + register.Off(default for plain realms / no App context) — unknown email → no user, just the uniform response.ExplicitEndpoint— the OTP-request endpoint stays strict (known users only); registration is a deliberate separate step viaPOST /api/account/native/register(see below).
A password-bearing but unconfirmed account is never served a native code — it must finish verification via the web link. See Applications → Self-registration posture.
By default registration is email-only — the username is the email and names
are optional. A realm (or App) can require more via the
Registration Fields policy. Both
the OTP-request and the explicit-register endpoints accept optional
FirstName / LastName for this:
POST /api/account/native/otp/request
Content-Type: application/json
{ "Email": "user@example.com", "FirstName": "Ada", "LastName": "Lovelace" }The client should read the resolved policy from GET /api/app-info and
render exactly what it reports:
{
"RegistrationFields": { "Email": "Required", "Username": "Off",
"Firstname": "Required", "Lastname": "Required" }
}If a required name is missing the endpoint returns 400 before the
uniform branch — this check is independent of whether the email exists, so it
leaks nothing. (The username is never required on native paths — it is always
the email.) A returning user signing in re-sends the fields; they are ignored on
login. On registration they are persisted on the new account.
When an App's posture is ExplicitEndpoint, sign-up is a separate call (so
the app can gate it behind its own ToS / profile UI) while sign-in stays
strict. Same uniform response, anti-enumeration jitter and per-IP rate limit
as the OTP request:
POST /api/account/native/register
Content-Type: application/json
{ "Email": "user@example.com", "FirstName": "Ada", "LastName": "Lovelace" }FirstName / LastName are optional unless the resolved
Registration Fields policy
(published at GET /api/app-info) marks them Required — then a missing one is
rejected with 400 before the uniform branch.
On an App subdomain whose posture is ExplicitEndpoint, an unknown email
creates the passwordless user and emails a registration code; redeem it at
/connect/token with grant_type=urn:cocoar:otp exactly like the JIT code
(it also confirms the mailbox). Under any other posture (or with native
grants off) the endpoint does nothing — it never doubles the JIT path.
Step 1 — request a link:
POST /api/account/magic-link/request
Content-Type: application/json
{ "Email": "user@example.com" }The user receives an email containing a link of the form
https://<realm-host>/magic-login?userId=<guid>&token=<token>. Capture userId and token in the app (e.g. via a Universal Link / App Link that intercepts that URL).
Step 2 — redeem:
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:cocoar:magic
&client_id=<client_id>
&user_id=<guid>
&magic_token=<token>
&scope=openid offline_access
&totp_code=000000 # only if the user has TOTP 2FA enabledA two-step ceremony: fetch a challenge, sign it on-device, redeem the assertion.
Step 1 — begin (anonymous; client_id selects the per-client RP-ID):
POST /connect/passkey/begin
Content-Type: application/x-www-form-urlencoded
client_id=<client_id>{
"ceremonyId": "…",
"options": { /* verbatim WebAuthn assertion options (challenge, rpId, userVerification: "required", empty allowCredentials) */ }
}Step 2 — sign options on-device with the platform authenticator (iOS: ASAuthorizationPlatformPublicKeyCredentialProvider assertion request). User verification is required, which is why the passkey grant is treated as MFA and needs no totp_code. The credential is discoverable/usernameless, so the user is identified from the signed assertion — the app doesn't send a username.
Step 3 — redeem the assertion:
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:cocoar:passkey
&client_id=<client_id>
&ceremony_id=<ceremonyId from step 1>
&assertion=<FIDO2 assertion as JSON>
&scope=openid offline_access
&resource=<api-audience> # optionalThe ceremony is single-use and short-lived; a ceremony_id cannot be replayed.
A passkey is bound to one RP-ID, so the first time a user opens a new app they have no passkey for it yet. Bootstrap = "sign in once with another factor, then add a passkey for this app". This runs once per (user, app) — platform passkeys live in the iCloud/Google keychain, so a reinstall does not lose it.
-
Sign in with OTP or magic-link (Flow 1 or 2) → obtain an access token.
-
Begin enrollment (Bearer-authenticated with that access token):
POST /connect/passkey/enroll/begin Authorization: Bearer <access_token>
{ "ceremonyId": "…", "options": { /* WebAuthn attestation (create) options */ } } -
Create the credential on-device (iOS:
…PublicKeyCredentialProviderregistration request) under the app's RP-ID. -
Complete enrollment (Bearer-authenticated):
POST /connect/passkey/enroll Authorization: Bearer <access_token> Content-Type: application/json { "ceremonyId": "…", "attestation": { /* FIDO2 attestation result */ } }
From then on, the app uses Flow 3 (passkey) as the steady-state login.
A profile screen (web BFF or native) lists the user's passkeys and lets them remove a lost or stale one — cookielessly, from the same access token the app already holds (no Modgud session needed). Both endpoints are Bearer-authenticated, gated by the realm's native-grants flag, and strictly owner-scoped: a caller only ever sees or deletes credentials owned by the token's subject.
List the subject's passkeys:
GET /connect/passkey
Authorization: Bearer <access_token>[
{ "Id": "0190…", "DisplayName": "Passkey", "CreatedAt": "2026-06-28T09:00:00Z", "LastUsedAt": "2026-06-28T10:15:00Z" }
]Id is the credential's stable management id (not the raw WebAuthn credential id) — pass it to delete. LastUsedAt is omitted while the passkey has never been used.
Revoke one passkey:
DELETE /connect/passkey/{id}
Authorization: Bearer <access_token>Returns 204 No Content. A deleted passkey can no longer satisfy a urn:cocoar:passkey assertion. An id that doesn't exist or belongs to another user is a 404 (never a 403) — the endpoint is not a cross-user credential-existence oracle.
-
Store the refresh token in the Keychain. Access tokens are short-lived by design (per-realm native lifetime, default 15 min).
-
Refresh with the standard grant — no user interaction:
POST /connect/token grant_type=refresh_token&client_id=<client_id>&refresh_token=<rt>&resource=<api-audience>
-
Revocation: an admin disabling/deleting the user, a password reset, or "log out everywhere" rotates the user's security stamp — the next refresh then fails (
invalid_grant), so the app must fall back to a fresh sign-in. Reference (opaque) access tokens (the default) are additionally revocable server-side immediately.
| Condition | Response |
|---|---|
| Realm hasn't enabled native grants (token endpoint) | unsupported_grant_type |
| Realm hasn't enabled native grants (OTP-request / native-register endpoint) | 400 NativeGrants.Disabled — an explicit error, not a silent "code sent". Enable the grants in Realm Settings. |
Client lacks the gt:urn:cocoar:* permission |
unauthorized_client |
| Wrong/expired code, link, or passkey assertion | invalid_grant — uniform message + jitter (anti-enumeration); don't parse it for "which part was wrong" |
| TOTP required but missing/invalid (OTP & magic flows) | invalid_grant ("Two-factor authentication is required; supply totp_code.") |
| Rate limit hit (OTP request, passkey begin, token endpoint) | 429 Too Many Requests — back off. The per-IP ceilings are realm-configurable under Realm Settings → Rate Limits (defaults unchanged). |
| Passkey begin while realm has no primary domain | 503 (admin must set the realm/client RP-ID) |
| Passkey list / delete without a valid Bearer token | 401 Unauthorized |
| Passkey delete of an unknown id or one owned by another user | 404 Not Found — never 403, so it is not a cross-user existence oracle |
| Passkey enroll / list / delete while the realm has native grants off | 400 NativeGrants.Disabled |
GET /.well-known/openid-configuration advertises the custom grants in grant_types_supported (urn:cocoar:otp / :magic / :passkey) when the realm has them enabled, alongside the standard endpoints.
For the admin: realm native-grants flag on → public client created with the needed urn:cocoar:* grants (+ refresh_token) and scopes (openid, offline_access, API scopes) → per-client WebAuthn RP-ID set → app serves AASA on that apex.
For the app team: client_id + realm host → implement OTP and/or magic-link for first sign-in → passkey enrollment bootstrap → passkey steady-state login → Keychain token storage + refresh + re-auth-on-invalid_grant.