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
- Authentication enabled for your environment, with at least one sign-in method and your Auth Environment ID to hand (see Enable authentication)
- The sign-in methods you want configured — Email magic links, Google OAuth, or Apple OAuth
- A redirect URL on your environment’s allow list (see Redirect URLs)
- A working Portal integration (see Getting Started)
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.
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 copySharedPreferences 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:
<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 ofmyapp://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.
- Android
- iOS
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
singleToplaunch mode the Flutter template generates. It delivers the redirect to the running activity, andapp_linkspicks it up from there, so there is noonNewIntentcode to write.
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 oneapp_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:
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.
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.
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.AUTH_METHOD_UNAVAILABLE. That is not retryable — read
getMethods().allowedAuthMethods and present the method as unavailable instead.
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.
Email 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.
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.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:
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.
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 returnsPortalTotpRequired 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():
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.
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.totpLink to it verbatim, and offer
totpSecret for users who cannot scan:
Initialize Portal from the session
Pass the session toinitialize() as session, in place of apiKey.
Everything else about Portal is unchanged, including how you configure
backups.
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().
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, viaautoCreateWallet.
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.
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 asshared_preferences is deleted with the app, which makes it a
per-install marker: clear the persisted session the first time an install
launches.
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.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.
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 a401, 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:
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.
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.
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 aPortalException. Branch on its code.
PortalAuth calls throw AUTH_* codes:
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.