Skip to content

Native app integration (iOS / Android / headless) ​

Two ways to sign a native app in

The standards-based, interoperable default for native apps is Authorization Code + PKCE via the system browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android, per RFC 8252). It works with off-the-shelf OAuth SDKs such as AppAuth, and Modgud fully supports it — see OAuth & OIDC concepts and the OAuth / OpenIddict implementation guide.

The cookieless grants documented on this page are something else: Modgud's optional first-party profile for apps that want a fully native, browser-free login UX — passkeys, email OTP, magic link — with no system-browser hop at all.

Modgud also lets native apps sign a user in without a browser redirect and without a cookie — the app talks to the token endpoint directly. This suits iOS/Android apps, CLIs, and other clients that want a native passwordless UX instead of a browser hand-off.

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):

GrantFactorBest for
urn:cocoar:otpEmail one-time codeFirst sign-in / fallback
urn:cocoar:magicMagic-link tokenFirst sign-in / fallback
urn:cocoar:passkeyWebAuthn 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.


Part A — Admin setup ​

What a realm administrator must configure in Modgud before the app can connect.

1. Enable native grants for the realm ​

Realm Settings → Native Passwordless Grants → enable. Optionally adjust the access-token lifetime (default 15 min). The refresh token has no native setting of its own: it lives as long as the client session — the idle lifetime of the realm's, App's or client's client-session policy (default 30 days), renewed on every refresh within the session's absolute lifetime. This is the master gate; while it's off, /connect/device-style native endpoints return disabled and the grants are rejected.

2. Create the OAuth client ​

OAuth Clients → Create:

  • Client type: Public — native apps cannot keep a secret, so they are public clients (no client_secret; identified by client_id only).
  • 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.) Add refresh_token as 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_secret adds client authentication on top of the user's factor. If such a BFF also acts machine-to-machine (e.g. minting invite codes via client_credentials), that is a separate client: a single client cannot hold both user-flow and client_credentials grants (see strict grant separation). So a dual-role BFF runs two clients — a login client carrying urn:cocoar:otp (public for a single shared secret, or confidential for client-auth on the redeem) plus a separate SA-linked client for client_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.

Apps without a web origin. iOS and macOS apps report https://<RP ID> (Apple sets it after the associated-domains check), so they need nothing beyond the client's RP-ID. Other platforms let a native app sign an origin that is not a web origin, and Modgud accepts such an origin only when the App the client belongs to lists it under Native app origins (App, Sign-in tab, one per line; needs the App's passkey RP ID, the same value as the client's RP-ID):

  • Android. Credential Manager signs android:apk-key-hash:<base64url SHA-256 of the app's signing certificate>, set by the platform. Paste the SHA-256 fingerprint of the signing certificate as the Play Console or keytool shows it (37:12:A2:…), the bare hash, or the full origin; Modgud stores the origin. Each signing certificate is its own entry: Google Play app signing, a locally signed release build, a separate beta app. The RP ID domain serves /.well-known/assetlinks.json listing the app's package and certificate with the permission delegate_permission/common.get_login_creds; Android checks this before it lets the app use the RP ID. Modgud never serves it.
  • Any other native app (e.g. a desktop app whose WebAuthn call carries an origin of its own): enter exactly the origin the app signs, e.g. app://notes. The match is ordinal, so the entry must be byte-identical to what the app puts into its client data. An app that signs https://<RP ID> needs no entry.

Web origins (http, https) are refused in this list: pages are covered by the RP ID and by related origins, whose /.well-known/webauthn file is the RP's own consent. file and other schemes every app shares are refused too, because they cannot tell one app from another.

The list belongs to the App, not to a client: every client of the App whose ceremony runs on the App's RP ID accepts it. That covers a backend that begins and redeems the passkey grant with the App's web client for an assertion its native app produced. A presented origin that the App does not list, or a client of another App, is rejected as before. In the realm manifest the list is Settings.SignIn.PasskeyAppOrigins on the App.


Part B — Client flows ​

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:

json
{
  "access_token": "…",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "…",   // only when offline_access was requested
  "scope": "openid offline_access"
}

Flow 1 — Email OTP ​

Step 1 — request a code (anonymous; uniform response, so it never reveals whether the email exists):

http
POST /api/account/native/otp/request
Content-Type: application/json

{ "Email": "user@example.com" }
json
{ "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 the gt:urn:cocoar:otp permission).

Step 2 — redeem the code for tokens:

http
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              # optional: send it along if you already have it (see Second factor)

If a second factor is owed, the answer is 400 mfa_required instead of tokens — see Second factor.

Native passwordless registration (JIT-on-OTP) ​

The 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 writes a short-lived pending registration for that address and emails a registration code. No user exists yet. Redeeming the code at /connect/token proves the mailbox and creates the account — confirmed, passwordless — in the same step that mints the tokens. No password, no separate registration call.

Registration before proof

A pending registration is a plain, expiring record keyed by the address: one per address, overwritten by the latest request, hard-deleted on proof (10-minute code lifetime, 3 attempts) or by the hourly sweep. Nothing identifying the person survives an unredeemed code, and a stranger typing your address can never occupy it — your own request replaces theirs.

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 → pending registration + code.
  • 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 via POST /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.

Required identity fields ​

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:

http
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 (the same response carries the target's SignIn: { Password, EmailCode, Passkey, MinimumLevel }, so the app can show only the sign-in methods it is allowed to use):

json
{
  "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.

Explicit registration endpoint ​

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 rate limits (per source, target, client and app) as the OTP request:

http
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 writes a pending registration and emails a registration code — no user yet; redeem it at /connect/token with grant_type=urn:cocoar:otp exactly like the JIT code (the proof creates the confirmed account). Under any other posture (or with native grants off) the endpoint does nothing — it never doubles the JIT path.

Step 1 — request a link:

http
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:

http
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             # optional: send it along if you already have it (see Second factor)

Flow 3 — Passkey (steady-state login) ​

A 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):

http
POST /connect/passkey/begin
Content-Type: application/x-www-form-urlencoded

client_id=<client_id>
json
{
  "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 multi-factor on its own and needs no second factor. 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:

http
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>        # optional

The ceremony is single-use and short-lived; a ceremony_id cannot be replayed.

First-run bootstrap — enroll a passkey ​

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.

  1. Sign in with OTP or magic-link (Flow 1 or 2) → obtain an access token.

  2. Begin enrollment (Bearer-authenticated with that access token):

    http
    POST /connect/passkey/enroll/begin
    Authorization: Bearer <access_token>
    json
    { "ceremonyId": "…", "options": { /* WebAuthn attestation (create) options */ } }
  3. Create the credential on-device (iOS: …PublicKeyCredentialProvider registration request) under the app's RP-ID.

  4. Complete enrollment (Bearer-authenticated):

    http
    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.

Manage passkeys — list & revoke ​

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:

http
GET /connect/passkey
Authorization: Bearer <access_token>
json
[
  { "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:

http
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.

Account-change notice. Adding a passkey (POST /connect/passkey/enroll) and removing one (DELETE /connect/passkey/{id}) mail the user the same "a change to your account" notice as the browser does.

Recent sign-in for passkey changes. With Passkey changes require a recent sign-in on (Realm Settings → Native Passwordless Grants, or per App), adding or removing a passkey needs a sign-in from the last 15 minutes — the token's auth_time, which a refresh does not renew. An older token is refused (RFC 9470) on POST /connect/passkey/enroll/begin, POST /connect/passkey/enroll and DELETE /connect/passkey/{id}:

http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="A more recent sign-in is required to change this account's passkeys.", max_age="900"

{ "error": "insufficient_user_authentication", "error_description": "…", "max_age": 900 }

The app then signs the user in again with any grant it uses — e-mail code, magic link or passkey — and repeats the call with the new access token. This protects the account against a stolen refresh token: it mints fresh access tokens, but not a fresh sign-in.

The e-mail address is the way back: a user whose passkey device is lost or broken signs in on the new device with an e-mail code or magic link, removes the lost passkey and enrolls a new one within the 15 minutes. If the target App's sign-in policy requires two factors and the user has none left, only the operator can help (recovery CLI).

The switch is off by default, so existing apps keep working. While it is off, the change goes ahead and Modgud logs a warning where it would have refused; switch it on once the app handles the 401.

Second factor ​

Whether a sign-in needs a second factor is decided by the target App's sign-in policy and the user's own second factor — see Sign-in levels. The App lists the methods it implements under App → Sign-in; the grants only accept what is listed (urn:cocoar:otp is refused with unsupported_grant_type when the target's e-mail code is off).

When the first factor is proven but a second factor is still owed, the OTP and magic-link grants no longer fail with invalid_grant (which would also burn the code). They answer:

http
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "mfa_required",
  "mfa_token": "…",
  "mfa_methods": "totp",
  "mfa_browser_methods": "…"
}

The mfa_token is short-lived (10 minutes), single-use, and bound to the user, the client and the first factor that was proven. mfa_methods lists the second factors the App can ask for natively; mfa_browser_methods lists those only the browser can ask for. If the user has to set up a second factor first, the answer is interaction_required.

You can send totp_code together with the very first request to save a round trip; mfa_required is only the answer when it is missing.

Continue natively — repeat the same grant_type with the token and the factor, and without username / otp_code:

http
POST /connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:cocoar:otp
&client_id=<client_id>
&mfa_token=<mfa_token>
&totp_code=000000

Continue in the browser — when the App does not implement the factor (the App's user's own factor not offered setting is RequireViaBrowser), start the normal authorization-code request with an extra mfa_token=<mfa_token> parameter. The login page opens directly at the missing factor, asks only for it, and returns to the App's redirect URI as usual.

Use the system browser

Open the URL with ASWebAuthenticationSession (iOS) or Custom Tabs (Android). Passkeys and password managers work there, and RFC 8252 advises against embedded web views. An embedded web view works for e-mail codes and TOTP, but not reliably for passkeys. Modgud cannot detect or prevent a web view and does not try.

A refresh fails with invalid_grant ("The application now requires a stronger sign-in") when the target App now requires more than the refresh token's sign-in proved; sign in again.

Tokens: storage, refresh, revocation ​

  • 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:

    http
    POST /connect/token
    grant_type=refresh_token&client_id=<client_id>&refresh_token=<rt>&resource=<api-audience>

    Refresh tokens are single-use and rotate on every redeem — a replayed (already-redeemed) refresh token is rejected with invalid_grant and revokes the whole token family, not just that one token. See Refresh Token.

  • 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.

Calling through a backend (BFF) ​

A web front end usually does not call these endpoints from the browser; its backend-for-frontend does, server-to-server. Then Modgud sees the backend's egress address for every user — and a per-source ceiling would treat the whole web population as one caller. The fix is a client capability, not a trust list:

  1. Make the BFF's client confidential and grant it cap:trusted-forwarder (client → Flows tab).

  2. On every auth call the BFF makes for a browser — OTP request, code redeem, magic link, … — authenticate with the client secret and send the user's address:

    http
    POST /api/account/native/otp/request
    Authorization: Basic base64(client_id:client_secret)
    Modgud-Forwarded-For: 203.0.113.42
    Content-Type: application/json
    
    { "email": "user@example.com" }
  3. Pass a 429 through to the browser with its Retry-After and body; do not retry.

The forwarded address is used for the source dimensions only; per-mailbox, per-client and per-App limits still bound the BFF as a whole. Sending the header without the capability (or without authenticating) is a 400 Auth.ForwarderNotTrusted; an entitled client that omits the header gets 400 Auth.ForwardedAddressRequired. Never put the BFF into ProxyAllowedNetworks — that setting is for your reverse proxy and would let the BFF choose the token issuer. Details: Rate limits → Trusted forwarders.

Errors ​

ConditionResponse
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:* permissionunauthorized_client
Wrong/expired code, link, or passkey assertioninvalid_grant — uniform message + jitter (anti-enumeration); don't parse it for "which part was wrong"
Second factor owed after the first factor (OTP & magic flows)400 mfa_required with an mfa_token — see Second factor
Second factor must first be set upinteraction_required
urn:cocoar:otp while the target App's e-mail code is offunsupported_grant_type
Refresh after the App now requires a stronger sign-in than the refresh token provedinvalid_grant ("The application now requires a stronger sign-in")
Rate limit hit (any auth endpoint)429 Too Many Requests with Retry-After and { "error": "rate_limited", "policy", "dimension", "retryAfterSeconds" } — honour Retry-After, never retry automatically. Limits are per mailbox, per App, per client and per source, realm-configurable under Realm Settings → Rate Limits; see Rate limits.
Passkey begin while realm has no primary domain503 (admin must set the realm/client RP-ID)
Passkey list / delete without a valid Bearer token401 Unauthorized
Passkey delete of an unknown id or one owned by another user404 Not Found — never 403, so it is not a cross-user existence oracle
Passkey enroll / list / delete while the realm has native grants off400 NativeGrants.Disabled

Discovery ​

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.


Hand-off checklist ​

For the admin: the App's sign-in methods and minimum level set (App → Sign-in), 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.

Released under the Apache-2.0 License.