Skip to main content
With Client Auth, Portal identifies your end user and creates their client for you. Your app asks Portal to start a sign-in, receives the completed sign-in as a deep link, and gets back a PortalSession that it passes straight to Portal().initialize(). No login system of your own, and no server-side call to mint a Client Session Token. Your app owns three things the SDK deliberately does not: when a sign-in starts, how the authorize URL is opened, and how the redirect gets back to you. PortalAuth never opens a browser and never listens for deep links.
Client Auth requires portal_flutter 2.0.0 or later, paired with portal_flutter_android and portal_flutter_ios 2.0.0. Those are built on portal-android 10.0.0 and PortalSwift 8.0.0.

Prerequisites

Installation

PortalAuth ships in portal_flutter alongside Portal. What it leaves to your app is opening URLs and receiving deep links, so add a package for each. This guide uses url_launcher and app_links, plus qr_flutter if your environment requires two-factor authentication. Any equivalent packages work.
Sessions are persisted for you, by the native SDK. On Android the token is encrypted with a key held in the Android Keystore and stored in a SharedPreferences file named PortalAuth. On iOS it is stored in the Keychain, on this device only. Both are keyed by your Auth Environment ID, in a namespace separate from the one holding MPC key shares, so ending a session can never touch a wallet. The token never crosses the platform channel. Dart only holds a PortalSession: an opaque handle to the native session, valid for the lifetime of the app process. The minimum platform versions are unchanged: Android minSdk 24 and iOS 15.0.

Exclude the session from backups (Android)

Android Auto Backup and device-to-device transfer copy SharedPreferences but not Keystore keys, so a restored device gets PortalAuth.xml full of ciphertext whose key no longer exists. The SDK handles that safely — the entry is cleared and restoreSession() reports “not signed in” — but you can keep it from happening at all by excluding the file:
Point the <application> tag in android/app/src/main/AndroidManifest.xml at both, since which one applies depends on the OS version the backup is taken on:
This is a nicety, not a requirement — the user signs in again either way. It buys you a cleaner restore: an excluded file means a restored device shows the sign-in screen because nothing was ever there, rather than because the SDK found something unreadable and cleaned it up.

Register your redirect

The OS only delivers a redirect to your app if you register its scheme natively. For a redirect URL of myapp://auth/callback, register the scheme on each platform and turn off Flutter’s built-in deep linking, so the URL reaches your app_links listener instead of being pushed onto the navigator as a route.
In android/app/src/main/AndroidManifest.xml, add an intent filter and the deep-linking flag to the activity that hosts Flutter:
  • android:exported="true" is required on Android 12 and later for an activity that declares an intent filter. Without it the app fails to install.
  • Keep the singleTop launch mode the Flutter template generates. It delivers the redirect to the running activity, and app_links picks it up from there, so there is no onNewIntent code to write.
The redirect carries a single-use grant that handleRedirect() exchanges for the user’s session. A custom scheme is not exclusive on Android — any installed app can declare the same myapp:// intent filter and receive that grant instead of you. For production, prefer a verified App Link on a domain you own (https://example.com/auth/callback), which Android delivers only to your app. Custom schemes are fine for development, or when you accept that risk.
To use an App Link, pass the HTTPS URL as your redirectUrl and swap the intent filter for a verified one. android:autoVerify only takes effect once your domain hosts an assetlinks.json that names your app:

Listen for redirects

Start one app_links listener before runApp. uriLinkStream replays the link that cold-started the app to its first subscriber, so a listener started later — from a widget’s initState, say — can miss the very redirect that launched the app. A cold-start link also arrives before PortalAuth is configured, so hold on to the latest link until your sign-in code is ready to take it:
The redirect URL must be byte-identical in three places: your environment’s allow list in the dashboard, the redirectUrl you pass to PortalAuth, and the native registration above. A mismatch between the first two fails the sign-in with a 401; a mismatch between the last two means the redirect silently never reaches your app. See Redirect URLs.

Configure PortalAuth

PortalAuth() always returns the same instance, backed by a single native PortalAuth. Configure it once at startup:
A blank authEnvironmentId or redirectUrl throws a PortalException with code AUTH_INVALID_ARGUMENT. Calling any other PortalAuth method before configure() throws AUTH_NOT_CONFIGURED.
Call configure() again only when the auth environment or the account-abstraction mode actually changes. Each call replaces the native instance, and with it the memory that lets a re-delivered redirect replay instead of failing (see Complete the sign-in) — so do not call it from a widget’s build or initState.
isAccountAbstracted is fixed by configure(), and it only takes effect on a user’s first sign-in. A returning user keeps the client they already have.

Check which methods are enabled

getMethods() reports what your environment allows, so you can render only the buttons that will work. It has no side effects and is safe to retry.
allowedAuthMethods holds PortalAuthMethod.emailMagicLink, PortalAuthMethod.google and PortalAuthMethod.apple. Methods a newer backend has added but this SDK version does not recognize are dropped. autoCreateWallet is a signal for your app — nothing in the SDK or the API acts on it. See Create or reuse the wallet.
A two-factor requirement is not visible here. It only appears when a sign-in is completed, so your redirect handler must always be ready for it.

Start a sign-in

Google or Apple

loginWithGoogle() and loginWithApple() return a PortalAuthorizeUrl. They do not open a browser and do not wait for the sign-in to finish — your app opens the URL, and the result arrives later as a deep link.
Fetch a fresh authorize URL for every attempt. A single backend response shares one single-use state across both providers, so caching a URL — or starting a Google sign-in and then an Apple one from the same response — invalidates the other.
LaunchMode.externalApplication opens the system browser, which shares the user’s existing Google or Apple sign-in. An in-app browser view works for the redirect too, as long as it hands the redirect to the OS rather than loading it itself — the URL has to reach your app_links listener.
If the provider is not enabled for your environment, the call throws AUTH_METHOD_UNAVAILABLE. That is not retryable — read getMethods().allowedAuthMethods and present the method as unavailable instead.
A provider that is enabled but incompletely configured behaves differently, and worse: it fails the whole call rather than omitting its own URL, so a half-configured Apple takes Google down with it even though getMethods() reports both as available. It surfaces as AUTH_LOGIN_WITH_GOOGLE_ERROR or AUTH_LOGIN_WITH_APPLE_ERROR carrying a 401, which the backend gives three indistinguishable meanings — an invalid Auth Environment ID, a redirectUrl that is not on the allow list, or exactly this. If both providers start failing at once, check the provider credentials in the dashboard before suspecting your code.
Portal identifies end users by email. A user who signs in with Apple and picks Hide My Email signs in with a relay address, so they become a different end user — with a different wallet — from the same person signing in with Google or a magic link.
sendMagicLink() completes once Portal has handed the email off for delivery. It tells you nothing about the eventual sign-in, which arrives as a deep link when the user opens the link.
The SDK trims and lowercases the address for you, so you can pass a text field’s value as it is. A blank address throws AUTH_INVALID_ARGUMENT before any network call.
Every call sends a real email, and sends are rate limited to 10 per address per minute. Never retry automatically; make a resend an explicit user action. Going over the limit throws AUTH_RATE_LIMITED.
Calling sendMagicLink() without a magicLink configuration throws AUTH_MAGIC_LINK_NOT_CONFIGURED before any network call is made.

Complete the sign-in

handleRedirect() is the single completion path for every method. Give it the incoming URL and it returns a PortalAuthResult, returns null because the URL was not a Client Auth redirect, or throws. PortalAuthResult is a sealed class with two cases, so a switch covers every outcome:
Feed it from the listener you started before runApp. Once PortalAuth is configured, take the pending link and subscribe to the stream before anything awaits, and run that link and every later one through a single queue. A link that arrives while startup is still running then waits its turn instead of being emitted to no one, and two links never complete at the same time. Each step keeps the URI it was queued with, so a later link cannot replace one that is still waiting.
handleRedirect() returns null for any URL that is not a Portal redirect for the configured redirectUrl, which makes it safe to call for every link your app receives, alongside your other routes. Re-delivery is handled for you. The native instance remembers the grant it last exchanged successfully, and the result it produced, so a redirect the system delivers twice resolves to the same result rather than the backend’s single-use rejection. Only successful exchanges are remembered, so a redirect that failed on a dropped connection stays retryable. That memory lives in the native instance and this process: configure() and clearPersistedSession() reset it, and it does not survive the app being terminated.
Because a redirect can be replayed, anything you do with the result has to be safe to run twice. onSignedIn() above may be called again for the same session, so make it idempotent — adopt the session and move on if you already hold it, rather than re-running one-time setup or pushing a second screen.
After the app is terminated, the OS can hand a relaunched app the same link it already exchanged. With nothing remembered, handleRedirect() sends the spent grant to the backend and throws AUTH_HANDLE_REDIRECT_ERROR, even though the session it created is sitting in storage. If a pending link fails, fall back to restoreSession() before sending the user back to the sign-in screen.
Route every redirect through exactly one handleRedirect() call. Two code paths that each see the same URL will each try to exchange it, and the second one fails. On success the session is persisted before it is returned, so a storage failure fails the sign-in rather than handing you a session that will not survive a restart.

Handle two-factor authentication

If your environment requires a second factor, a completed sign-in returns PortalTotpRequired instead of a session. Nothing is persisted yet. On success, verifyTotp() returns the same PortalAuthenticated the non-TOTP path produces, with the session already persisted — hand it to the same place you would hand a session from handleRedirect():
The code is trimmed, and must be at least six digits — an environment can enroll eight-digit codes. Anything else throws AUTH_INVALID_ARGUMENT before the network call, so a typo never costs the user a live code. A userJwt the SDK cannot read throws AUTH_INVALID_USER_JWT, also before the network call.
A rejected code and an expired userJwt both surface as AUTH_TOTP_REJECTED, so the SDK cannot tell you which happened. Prompt for the next code from the authenticator app; if that keeps failing, start a new sign-in. There is no way to refresh a userJwt.
Hold the pending PortalTotpRequired somewhere that outlives the widget that shows the prompt, such as your app state, not in a State object that a navigation change can dispose of. The redirect itself replays as the same TOTP step, and once verifyTotp() accepts the code that grant replays as the session instead, so a redirect re-delivered afterwards will not send an already-authenticated user back to the prompt.
Rendering the QR code is your app’s job; Portal does not ship a QR widget. Any Flutter QR package works — pass totpLink to it verbatim, and offer totpSecret for users who cannot scan:
For what the second factor is and how to reset an enrollment, see Two-factor authentication.

Initialize Portal from the session

Pass the session to initialize() as session, in place of apiKey. Everything else about Portal is unchanged, including how you configure backups.
Pass exactly one of apiKey and session. Passing both, or neither, throws a PortalException with code INITIALIZATION_ERROR. A session whose native side is gone — after a sign-out or an invalidation — throws SESSION_INVALIDATED; sign the user in again. clientId and isAccountAbstracted on PortalAuthenticated are only available at sign-in time. Afterwards, read them from Portal().getClient().
PortalSession is a handle, not a credential. Never persist session.id or try to parse it: it is only valid in the process that received it. On the next launch, get a fresh session from restoreSession().
Portal().apiKey is an empty string when Portal was initialized with a session. Do not read it or forward it anywhere: it carries no credential. The bearer token is resolved per request on the native side and deliberately not exposed.
The session token is attached only to Portal’s own hosts. An rpcConfig entry for your own or a third-party gateway is called without it, so an end user’s session token is never handed to a third party.

Create or reuse the wallet

A returning end user keeps the client and wallet they already have, so check for one before creating anything. Create a wallet only when the environment asks you to, via autoCreateWallet.
A restore on launch and a redirect can arrive at the same time, so run one adoption at a time; otherwise two code paths can both decide the user has no wallet. See Create a wallet and Recover a wallet for the full wallet lifecycle.

Restore the session on launch

restoreSession() rebuilds the session from secure storage, without a network call, so a returning user does not sign in again.
null means “not signed in”, and that includes a stored session the SDK can never read again — on Android, ciphertext whose Keystore key is gone after a backup restore. No caller action makes those readable, so the SDK clears them and reports null rather than failing your launch. AUTH_SESSION_STORAGE_FAILURE is reserved for storage failing this time, which may pass on the next attempt.
A restored session is a credential worth trying, not proof that it is still valid. Validity is only discovered on the first authenticated call, which invalidates the credential on a 401 and fires onSessionInvalidated.

Start a fresh iOS install from a clean slate

Keychain items survive app deletion, so on iOS a reinstalled app can find the previous install’s session and sign that user straight back in. App storage such as shared_preferences is deleted with the app, which makes it a per-install marker: clear the persisted session the first time an install launches.
Call it in startSignInFlow, right after configureAuth() and before takePending(). A first launch can be opened by a redirect, and that link is completed before restoreSession() is ever reached, so the check has to run first. A link that arrives in the meantime waits in DeepLinks. If the clear fails, the flag stays unset and the next launch tries again. Set it from onSignedIn() as well, or that retry deletes the session the user just signed in with.

End the session

There are three ways a session ends, and they are not interchangeable.
After clearSession(), call initialize() with the session from the next sign-in before using Portal again. On a Portal initialized with a Client API Key, or one that is not initialized, the call does nothing — there is no session to end. It does not emit on onSessionInvalidated: you already know about a sign-out you asked for.
clearPersistedSession() is not a sign-out. It stops a future restoreSession() from returning the session, but a Portal already holding the credential keeps signing transactions. To sign a user out, call Portal().clearSession().
There is no server-side revoke endpoint, so both operations are local sign-outs. The token itself stays valid until the backend expires it. See API Keys for session lifetimes.
Neither operation deletes the wallet’s signing shares — that is what the separate storage namespace buys you. The session lifecycle and the wallet lifecycle are separate: when the user signs in again, they continue with the same wallet, provided that wallet is otherwise still available to them.

Handle session invalidation

When the backend rejects the session with a 401, the session ended somewhere your app cannot see and the user has to sign in again. Portal().onSessionInvalidated is a broadcast Stream<void> that emits when that happens. Portal() is a singleton and the stream lives on it, so it carries across initialize() calls. Subscribe once, from something that lives as long as your app — not from a widget — and turn the event into state your UI reads:
The stream emits at most once per session. It never emits for a Portal initialized with a Client API Key, which is custodian-owned and has no session to end, and it is not emitted by clearSession(). After it emits, Portal calls fail with SESSION_INVALIDATED until the user signs in again and initialize() runs with the new session. By the time it emits, the SDK has already deleted the stored session — but only if it is still the one that was rejected, so a sign-in completed in the meantime is kept. If that delete fails, a restore on the next launch returns the dead session, and its first 401 deletes it again.
Do not call clearPersistedSession() in response. It deletes whatever is stored, waiting for an in-flight handleRedirect() to finish first, so a user who completes a new sign-in while the old session is being rejected has that new session wiped. It also resets the memory that lets a re-delivered redirect replay.
What triggers it is a 401 on any request that carried the Portal session token, including a native MPC operation — creating, backing up or recovering a wallet, or signing — whose own authentication failed. On Android, a 401 from a Hypernative scan triggers it too. A request that carried no Portal credential, such as one to a third-party RPC gateway, can never sign your user out.
A broadcast stream does not replay. A listener added after the event has fired never sees it, so subscribe before the first initialize(session:) and store what you learn rather than acting on it only in the moment.
Still handle errors from your wallet and signing calls as well as listening here. The stream tells you the session ended, not which call failed, and an MPC operation that hit the rejection still throws with its own code — such as CREATE_WALLET_ERROR — where it happened.
As with a sign-out, an invalidated session does not delete the wallet’s signing shares — only the credential is gone. Wallet state cannot be read without a session, so treat it as unknown until the user signs in again rather than as evidence the wallet is missing.

Handle errors

Every failure is a PortalException. Branch on its code. PortalAuth calls throw AUTH_* codes:
How you recover from AUTH_SESSION_STORAGE_FAILURE depends on where it came from:
  • restoreSession() or clearPersistedSession(): storage failed this time and may work on the next attempt, so retrying is right.
  • handleRedirect() or verifyTotp(): the session is persisted before it is returned, so the grant or code has already been spent at the backend and only the local write failed.
    • iOS: call the same method again with the same URL or userJwt. The SDK finishes the write and returns the session without re-sending the grant. That only works while the native instance still remembers it: configure() and clearPersistedSession() reset it, and it does not survive the app being terminated.
    • Android: nothing is remembered, so start a new sign-in.
Portal calls made with a session that cannot be used throw one of these, whichever method you called: PortalException.requiresReauthentication is true exactly when the code is SESSION_INVALIDATED, so you can check it after any call:
The call-named codes cover transport failures and backend rejections alike, so the same AUTH_HANDLE_REDIRECT_ERROR can mean a dropped connection — retryable — or a spent grant — not. message carries what the native SDK reported, such as the HTTP status and the backend’s error. Do not tell a user their link expired when the network was the problem.

Next Steps

Now that your user is signed in, create a wallet and send tokens. For the dashboard and provider configuration behind this flow, see Authentication.