>_devkit
zitadel-production
skills/zitadel-production/

references/mobile.md

Mobile and native clients

A native application needs its own registration. Nothing from a Web application transfers: not the type, not the auth method, not the redirect URIs.

Your API changes not at all. Same JWKS verification, same app_user row keyed on sub, same permission checks, provided you validate audience against the project ID rather than a client ID. That is the payoff of putting identity in an IdP: a new client type is a new registration, not backend work.

Registration

Projects, your project, Applications, New.

SettingValue
TypeNative
Auth MethodNone (PKCE), which ZITADEL forces for Native
Grant TypesAuthorization Code + Refresh Token
Auth Token TypeJWT

A mobile application can never hold a client secret. Anyone can unpack an IPA or APK and read it, so it is not a secret. This is the case where public-client PKCE is genuinely correct rather than a compromise, and it is why that mode exists in the specification at all.

Redirect URIs

Register every environment you actually use:

https://app.yourco.com/callback        # App Link / Universal Link, preferred
com.yourco.app://callback              # custom scheme
exp://127.0.0.1:8081/--/callback       # Expo dev client
http://localhost:8888/callback         # loopback, for testing without an app

Enable Development Mode while building, since exp:// and http:// need it. Turn it off for production.

Prefer App Links and Universal Links over custom schemes. Any other application on the device can register the same custom scheme and hijack your callback. App links are bound to a domain you control, which costs real setup: an /.well-known/apple-app-site-association and an /.well-known/assetlinks.json served from your domain and matching your bundle ID and signing certificate. Budget an afternoon. Ship app links; use a custom scheme only in development.

The loopback redirect (http://127.0.0.1:PORT/...) is sanctioned by RFC 8252 precisely because there is often no application yet. It is also how you test the whole flow before writing any client code. See testing.md.

Session lifetimes differ from web

Mobile users expect to open an app after three weeks and still be signed in. A web session expiring in an hour is fine; a mobile one is a support ticket.

Instance Settings, OIDC Token Lifetimes:

  • Access token: 5 to 15 minutes. Shorter matters more here, because this is a

bearer credential sitting on a device you do not control.

  • Refresh token idle expiry: 30 to 90 days. This is what actually keeps people

signed in.

  • Refresh token absolute expiry: 6 to 12 months, forcing a real

re-authentication eventually.

Request offline_access in your scopes and enable the Refresh Token grant, or you get no refresh token at all.

Client implementation

With Expo, expo-auth-session handles PKCE and discovery:

import * as AuthSession from 'expo-auth-session'
import * as SecureStore from 'expo-secure-store'

const discovery = AuthSession.useAutoDiscovery('https://auth.client.com')

const [request, response, promptAsync] = AuthSession.useAuthRequest(
  {
    clientId: NATIVE_CLIENT_ID,
    redirectUri: AuthSession.makeRedirectUri({ scheme: 'com.yourco.app' }),
    scopes: ['openid', 'profile', 'email', 'offline_access'],
    usePKCE: true,
  },
  discovery,
)

Outside Expo: AppAuth-iOS, AppAuth-Android, or react-native-app-auth.

Three non-negotiables

Use the system browser, never an embedded WebView. ASWebAuthenticationSession on iOS, Custom Tabs on Android. This is RFC 8252 and it is not pedantry: a WebView lets the application observe credentials, breaks SSO with other apps, breaks password managers, and breaks passkeys entirely. expo-auth-session does the right thing by default; hand-rolling with a WebView does not.

Store tokens in the platform secure store. Keychain on iOS, Keystore on Android, via expo-secure-store or equivalent. Never AsyncStorage.

Persist rotated refresh tokens. ZITADEL rotates on use, so write the new one back every time. Keeping the old one means the next refresh fails and the user is silently logged out, which is very hard to reproduce from a bug report.

Logout

Clearing local tokens leaves the ZITADEL session alive, so the next login is instant and silent. Users read that as "logout is broken". Call the end_session endpoint with the id_token_hint and a registered post-logout redirect URI.

Same issue exists on web, more visible on mobile.

The bundle identifier is the permanent decision

The redirect scheme derives from your bundle ID, and the app stores bind a listing to it. Changing it later means publishing a new application, not updating one. Convention (RFC 8252) is reverse DNS on a domain you own:

Bundle ID:  dev.yourco.<appname>
Scheme:     dev.yourco.<appname>://callback

Worth fixing in a naming convention alongside other identifiers.

By contrast, ZITADEL application registrations are disposable. Delete one, recreate it, change its type: nothing downstream breaks except a client ID in a config file. That is the opposite of the instance domain, which is baked into login names at first boot. Do not overthink the application; do think about the bundle ID.