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. 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

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 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 your <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 of myapp://auth/callback, add an intent filter to the activity that should receive it, and set both activity attributes shown here:
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.
  • 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.
To use an App Link instead, 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:
Under singleTask the new Intent does not replace the one getIntent() returns, so override onNewIntent and call setIntent yourself:
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 scheme and host in the manifest 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.

Create a PortalAuth

Create one long-lived PortalAuth and reuse it.
A blank authEnvironmentId or redirectUrl throws IllegalArgumentException.
Build the instance lazily — by lazy as above, a DI provider, or Application.onCreate(). PortalAuth takes no Context: the constructor reaches the session store, which resolves the application context through androidx.startup. Anything that constructs it before that initializer has run is too early.
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.
Methods a newer backend has added but this SDK version does not recognize are dropped from 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.
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.
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.
If the provider is not enabled for your environment, the call throws PortalException.Auth.AuthMethodUnavailable. 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 arrives as 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. The SDK passes it through untranslated rather than guessing which. If both providers start failing at once, check the provider credentials in the dashboard before suspecting your code.
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.
Lowercase and trim the address before you call sendMagicLink(). The API rejects a non-lowercase address with a 400, and the SDK passes what you give it through unchanged — so a plain text input breaks for any user who capitalizes.
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.
Calling 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.
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.
That memory lives on the instance and in this process, which is why a single long-lived PortalAuth matters. It does not survive process death, and under launchMode="singleTask" the deep-link Intent can outlive the process that received it: a recreated process is handed the same Intent, exchanges an already-spent grant, and fails with PortalException.Api.HttpBadRequest even though the session it created is sitting in storage, restorable. Two things avoid it — call restoreSession() first on every launch and skip handleRedirect() entirely when it returns a session, and clear the Intent once you have consumed it, as the example does.
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 returns AuthResult.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.
A rejected code, an expired userJwt, and one that has already been used all surface the same way, 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.
A 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 as credentials. 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.apiKey is an empty string on a session-backed instance and is deprecated. Do not read it or forward it anywhere, such as into a webview: it carries no credential. The bearer token is resolved per request and deliberately not exposed.
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, via autoCreateWallet.
See Create a wallet for the full wallet lifecycle, and Manage wallet lifecycle states for the helpers used above.

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.
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.

End the session

There are three ways a session ends, and they are not interchangeable.
After 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.
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 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 a 401, 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:
The callback fires at most once for an instance, and it is not fired by 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 is delivered once and is never replayed to a later subscriber. An Activity that subscribes in onCreate and closes in onDestroy therefore misses the event outright if the session dies while it is destroyed — during a rotation, say — and every subscription made afterwards is inert, so your app carries on looking signed in forever. Subscribe from something that lives as long as the Portal it belongs to, and store what you learn rather than acting on it only in the moment.
The listener runs on the thread that detected the rejection — an OkHttp dispatcher, Dispatchers.IO, or the WebSocket’s own scope — never the main thread. Post to your own scope before touching UI, and keep the listener short: it runs inline with the failing request’s bookkeeping.
Close the returned Closeable when its owner goes away. Until you do, the listener and everything it captures stay reachable for as long as the session does. Closing twice, or from inside the listener, is safe.
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.
Still handle errors from your wallet and signing calls as well as subscribing here. The callback tells you the session ended, not which call failed, and the failing operation has to be dealt with 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

Client Auth surfaces two families of exceptions. PortalException.Auth — a sign-in that could not be completed:
SessionStorageFailure is only retryable where nothing was consumed to get there. From restoreSession() or clearPersistedSession() it means storage failed this time and may work on the next attempt, so retrying is right. From handleRedirect() or verifyTotp() it means the opposite: the session is persisted before it is returned, so the grant has already been spent at the backend and only the local write failed. Retrying the same redirect gets a 400 for an already-used grant. Start a new sign-in instead.
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.
HttpBadRequest is every 400, not just a spent grant, so do not translate it to one meaning everywhere. sendMagicLink() returns it for an address that is not lowercase, and any malformed request returns it too — telling that user their sign-in link expired sends them to look for a problem that does not exist. Branch on where the call came from, as above, or read error.message for what the backend actually said.

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.