A custom mobile SSO workflow works when the customisation stays in your app and the sign-in itself stays standard. Run the identity provider’s page in the system browser: ASWebAuthenticationSession on iOS, Custom Tabs on Android. Return to the app through a callback you own, ideally a verified HTTPS link, and protect the exchange with PKCE and state. Validate the ID token, send audience-restricted access tokens to your APIs, and test renewal, revocation, logout and account switching as carefully as the first login. Verify Android and iOS separately. This is a vendor-neutral guide for native apps.
For the product context, see mobile SSO integration.
Why the system browser
RFC 8252 requires native apps to use an external user agent for OAuth, not an embedded web view. The system browser keeps the user’s password out of your process, lets the provider apply its own protections and shares the provider’s session, which is what makes single sign-on work across apps.
Custom screens are fine before and after sign-in: choosing an organisation, picking an account, or resuming a payment after authentication. Do not collect the identity provider’s password in a native form or a web view.
iOS: ASWebAuthenticationSession
- Create the session with the authorisation URL and a callback, and set a
presentationContextProvider. - Keep
prefersEphemeralWebBrowserSessionfalse when you want SSO. Setting it to true stops cookie sharing with Safari, so the user signs in every time. That suits shared devices or a deliberate “sign in as someone else”. - Use a custom URL scheme callback, or from iOS 17.4 the
ASWebAuthenticationSession.CallbackAPI, which can match an HTTPS host and path. - Treat a
canceledLoginerror as a normal user choice, not a failure to retry silently.
Android: Custom Tabs and verified App Links
- Open the authorisation URL with
CustomTabsIntentfrom the AndroidX Browser library. Custom Tabs run in the user’s browser and share its session. - Receive the redirect through an Android App Link: an HTTPS URL with an intent filter marked
android:autoVerify="true"and a Digital Asset Links file on your domain. A verified link cannot be claimed by another app, unlike a custom scheme. - If the device has no browser that supports Custom Tabs, fall back to the default browser, not a web view.
- Handle the redirect in a single activity and resume the pending transaction, even if the original activity was destroyed while the browser was in front.
The sign-in sequence
- Register each platform’s redirect URI with the provider. RFC 9700 requires exact string matching of redirect URIs, apart from the port of a loopback redirect.
- Generate a PKCE code verifier and an S256 challenge (RFC 7636), plus a random
stateand an OpenID Connectnonce. Store them with the pending transaction and the action the user was trying to do. - Open the system browser with the authorisation request. Add
login_hint,promptormax_agewhen the workflow needs a specific account or a fresh login. - On the callback, reject it unless
statematches a pending transaction that has not completed. A callback must never complete a different action. - Exchange the code with the PKCE verifier. Validate the ID token: issuer, audience, expiry and
nonce. - Store tokens in the Keychain on iOS, or encrypted with an Android Keystore key on Android. Keep them out of logs, URLs and crash reports.
- Call your APIs with an access token issued for that API. The resource server checks the audience and the scopes for each request.
An ID token says who signed in. It is not permission to call your API. A token issued for another service must be refused by yours.
Ownership of decisions
| Owner | Decides | Evidence it works |
|---|---|---|
| Identity team | Issuer, client registration, sign-in policy, step-up rules | Invalid redirects and clients are refused |
| Mobile team | Transactions, callbacks, token storage, lifecycle | Per-platform login, renewal and switch tests |
| API team | Audience, scopes, per-operation authorisation | Denied requests for each sensitive operation |
| Security operations | Signal interpretation and escalation | Reviewed response record and recovery path |
Otukan protects the authentication flow inside the app against hooking and runtime manipulation, and provides runtime threat signals your response policy can use. It does not replace the identity provider. Decide whether a signal affects the current action, the local session or a server-side step-up, and confirm each signal’s Android and iOS scope separately.
Test the full session
- Login: success, cancellation, denied consent, an unknown
stateand a replayed callback. - Renewal: expired access tokens, parallel requests during refresh and a lost refresh response. RFC 9700 requires refresh tokens for public clients to be sender-constrained or rotated; test that a used refresh token is refused.
- Logout: clear local tokens, then end the provider session with OpenID Connect RP-initiated logout where the provider supports it. Decide whether “sign out” means this app or every app using the provider session.
- Revocation: revoke the grant at the provider (RFC 7009 where supported) and confirm that renewal and API calls fail.
- Account switching: switch accounts, confirm cached tokens and data from the first account are gone, and confirm the new identity before resuming any sensitive action.
- Interruption: kill the app while the browser is open, return, and confirm the pending transaction resumes or fails cleanly.
Repeat the critical journey after a fresh install and an update, on both platforms. Shared Flutter or React Native code does not remove either platform’s tests.
- Redirect URIs match exactly, and unverified links cannot capture the callback.
- PKCE,
stateandnonceare checked on every sign-in. - APIs refuse tokens with the wrong audience.
- Used refresh tokens are refused.
- Logout and revocation have defined, tested scopes.
- Test evidence uses test accounts and non-secret references, never raw tokens.
The wider integration boundary is covered on integration overview.