PortalSession that it passes straight to
Portal. 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 registers a deep-link
handler.
Client Auth requires a version of
io.portalhq.android:portal-android that
ships the io.portalhq.android.auth package. If PortalAuth does not resolve,
upgrade to the latest release.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
There is nothing new to install.PortalAuth ships in
io.portalhq.android:portal-android alongside Portal, and it adds no
dependency of its own — no androidx.browser, no Credential Manager.
Sessions are persisted for you: the token is encrypted with a key held in the
Android Keystore, and the ciphertext is stored in a SharedPreferences file
named PortalAuth, keyed by your Auth Environment ID. That is deliberately a
separate namespace from the one holding MPC key shares, so ending a session can
never touch a wallet.
The minimum supported API level is unchanged at 23.
Exclude the session from backups
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 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, add an intent filter to
the activity that should receive it, and set both activity attributes shown
here:
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.android:launchMode="singleTask"delivers the redirect to the activity that is already running instead of starting a second instance, which is what lets your existing screen receive it.
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:
singleTask the new Intent does not replace the one
getIntent() returns, so override onNewIntent and call setIntent yourself:
Create a PortalAuth
Create one long-livedPortalAuth and reuse it.
A blank
authEnvironmentId or redirectUrl throws IllegalArgumentException.
Hold one instance, on
Application or as a DI singleton, rather than one
per Activity. handleRedirect() remembers the grant it last exchanged so a
re-delivered redirect replays instead of failing, and that memory lives on the
instance — one built per Activity is destroyed by the very configuration
change the memory exists to survive. Sessions are keyed by
authEnvironmentId, so two instances on the same environment would share the
persisted session anyway.isAccountAbstracted is fixed when you construct PortalAuth, 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.
allowedAuthMethods rather than surfaced as nulls.
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 an authorize URL. 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.
A Custom Tab (
androidx.browser) is the recommended surface: it keeps the user
in your app and shares Chrome’s cookie jar, so an already-signed-in Google
account needs no re-entry. A plain ACTION_VIEW to the system browser works
identically for the redirect, and this SDK depends on neither. Be careful with a
Custom Tab, though: its redirect also fires your intent filter, so the tab’s
own result and your deep-link handling can both deliver the same URL. Feed
handleRedirect() from one path only — see Complete the sign-in.PortalException.Auth.AuthMethodUnavailable. That is not retryable — read
getMethods().allowedAuthMethods and present the method as unavailable instead.
Email magic link
sendMagicLink() returns 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.
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.
sendMagicLink() without a magicLink configuration throws
PortalException.Auth.MagicLinkNotConfigured 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 either returns an AuthResult, returns null because the
URL was not a Client Auth redirect, or throws.
Read the URL from the launching Intent in onCreate and from every later one
in onNewIntent, and route both into the same call:
handleRedirect() returns null for any URL that is not this instance’s
redirect, carries no grant, or is longer than 8192 characters, which makes it
safe to call from a shared deep-link handler alongside your app’s other routes.
Re-delivery is handled for you. The grant that was last exchanged successfully,
and the AuthResult it produced, are remembered on the instance, so a redirect
the system delivers twice — an Intent that survives a rotation under
launchMode="singleTask", say — resolves to the same result rather than the
backend’s single-use rejection. Nothing is re-exchanged, and a replayed
Authenticated carries the same PortalSession instance. Only successful
exchanges are remembered, so a redirect that failed on a dropped connection
stays retryable.
The replay stops as soon as the remembered session is no longer live — after
portal.clearSession(), or after a 401 retired it. A
redirect delivered again past that point is exchanged with the backend, which
rejects the spent grant with PortalException.Api.HttpBadRequest. That is the
correct outcome: the alternative would hand you an Authenticated result for a
user who is signed out, whose getToken() throws SESSION_INVALIDATED on the
first call you make with it.
Route every redirect through exactly one handleRedirect() call. Two instances
each seeing 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 Keystore
write 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 returnsAuthResult.TotpRequired instead of a session. Nothing is persisted yet.
totpLink is an otpauth:// URI on a user’s first sign-in — render it as a QR
code for their authenticator app — and null once they are enrolled.
On success, verifyTotp() returns the same AuthResult.Authenticated the
non-TOTP path produces, so result.session is the PortalSession you carry on
with — hand it to the same place you would hand a session from
handleRedirect():
Hold the pending
userJwt somewhere that outlives the Activity — on
Application, or in a ViewModel — not in Activity state. A rotation
mid-prompt otherwise dead-ends on a grant that has already been spent. 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 component.
Any Android QR library works — pass
challenge.totpLink to it verbatim.userJwt the SDK cannot read throws PortalException.Auth.InvalidUserJwt
before the network call, so a malformed JWT never costs the user a live code.
For what the second factor is and how to reset an enrollment, see
Two-factor authentication.
Initialize Portal from the session
Pass the session ascredentials. Everything else about Portal is unchanged,
including how you configure backups.
credentials and apiKey are mutually exclusive. Passing both throws
IllegalArgumentException; passing neither throws
PortalException.Credentials.InvalidApiKey. A blank apiKey — empty or
whitespace — reads as absent rather than as a conflict, which is what makes
Portal(credentials = …) legal.
PortalConnect takes a PortalCredentials the same way — its raw apiKey
constructor is deprecated.
portal.createWebView() works on a session-backed Portal.
It passes portal.apiKey to the injected provider, but that provider never
authenticates with it — RPC goes to your configured gateway, and signing is
bridged back to the native SDK, which resolves the session token per request.The session token is attached only to RPC URLs that are Portal’s own —
portalhq.io or portalhq.dev and their subdomains. Any other rpcConfig
entry, including your own gateway, is called without it, so an end user’s
session token is never handed to a third party.A local development host is the case worth knowing about. localhost,
127.0.0.1 and 10.0.2.2 qualify only when the RPC URL’s host and port
match the apiHost you configured, because a local Portal API and a local
Anvil or Hardhat node share the same loopback address and differ only by port.
So with apiHost = "10.0.2.2:3000", an RPC URL on 10.0.2.2:3000 is
authenticated and one on 10.0.2.2:8545 is not — which is what you want: a
local node needs no Portal credential, and sending one would put a live session
token on a plain-http hop to a process that is not Portal.Create or reuse the wallet
A returning end user keeps the client and wallet they already have, so check the wallet’s lifecycle state 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 so a returning user
does not sign in again.
null means “not signed in”, and that includes a session the SDK can see but
can never read again — ciphertext whose Keystore key is gone, which is what an
Android Auto Backup or a device-to-device restore leaves behind. No caller
action makes those readable, so the SDK clears them and reports null rather
than failing your launch. SessionStorageFailure is reserved for storage
failing this time, which may pass on the next attempt.
End the session
There are three ways a session ends, and they are not interchangeable.clearSession() this Portal is spent: every authenticated call fails
with SESSION_INVALIDATED. Construct a new Portal from the next sign-in
rather than reusing it. On a Portal built from a Client API Key the call does
nothing — there is no session to end. Both operations throw
PortalException.Auth.SessionStorageFailure if the delete fails. For
clearSession() the in-memory token is dropped either way, so the instance is
spent even when the stored copy survives.
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
Authentication and 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 credential with a401, the session ended
somewhere your app cannot see and the user has to sign in again.
Subscribe where the Portal lives, and turn the one-shot callback into state
your UI can read whenever it next looks:
portal.clearSession() — you already know about a sign-out you asked for. It
never fires for a Portal built from a Client API Key, which is
custodian-owned and has no session to end.
What triggers it is a 401 on any request that carried the Portal session
token, wherever that request went — including a Portal API reached through your
own apiHost override, since the SDK sends the session token there too. What
cannot trigger it is a request the SDK never authenticated: portal.request(...)
against a third-party RPC gateway sends no Portal credential, so however that
gateway answers, it can never sign your user out.
The callback covers a
401 wherever it happens, native MPC operations
included. Those are worth knowing about because they get there differently: the
MPC binary makes its own HTTP calls, so a rejection inside createWallet,
backup, recover, or MPC signing is never visible to the SDK’s request
layer. The SDK reads the binary’s AUTH_FAILED result instead and retires the
credential through this same path.The operation itself still fails with PortalException.Mpc.MpcResultError
carrying that id, exactly as it did before — so existing catch blocks keep
working, and this callback is what tells you the session was the reason.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
Client Auth surfaces two families of exceptions.PortalException.Auth — a sign-in that could not be completed:
PortalException.Credentials.PortalCredentialError — a credential that could
not be turned into a token. Branch on its reason:
Transport failures are not remapped into
PortalException.Auth. A grant the
backend refuses — already used, expired, or issued for another environment —
surfaces as PortalException.Api.HttpBadRequest carrying the backend’s message.
A network failure surfaces as a raw IOException, and a request that gets no
answer in time as a java.net.SocketTimeoutException. See
Error handling for the full hierarchy and the
request timeouts that bound every call.